// ==UserScript== // @name 悬景 · HoverVista|鼠标悬停图片自动放大预览 // @namespace https://github.com/YDGG123 // @version 5.9.0 // @description 网页图片鼠标悬停自动放大工具:智能自适应、高清图后台升级、滚轮边界控制、配置备份与恢复 // @author 益达哥哥 // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_listValues // @grant GM_download // @grant GM_setClipboard // @grant GM_xmlhttpRequest // @grant GM_openInTab // @connect raw.githubusercontent.com // @connect * // @run-at document-end // @license GPL-3.0 // @homepageURL https://github.com/YDGG123/hover-image-zoom // @supportURL https://github.com/YDGG123/hover-image-zoom/issues // ==/UserScript== /* * 悬景 · HoverVista — 鼠标悬停图片自动放大预览 * Copyright (C) 2025-2026 益达哥哥 * SPDX-License-Identifier: GPL-3.0-or-later */ (function() { 'use strict'; // 幂等守卫:同一文档只初始化一次,避免脚本重复执行导致两个 dock、两套 hover 流互相抢锁。 // 两次执行共享同一 DOM,用 documentElement 上的标记做守卫。 if (document.documentElement.getAttribute('data-hv-inited') === '1') return; document.documentElement.setAttribute('data-hv-inited', '1'); // ★ 版本号动态取自脚本管理器(@version),避免每次升号后忘记同步这里的常量; // GM_info 不可用时回退到当前发布版本字符串。 const SCRIPT_VERSION = (function () { try { if (typeof GM_info !== 'undefined' && GM_info.script && GM_info.script.version) return GM_info.script.version; } catch (e) { } return '5.9.0'; })(); // 调试模式(URL 带 ?hvdebug=1):把智能升级器的诊断信息显示在信息浮层里,便于端到端排查 const HV_DEBUG = (function () { try { return /[?&]hvdebug=1/.test(location.search); } catch (e) { return false; } })(); // 存储读写封装 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); // 历史记录(最近悬停看过的图片;只存本机 GM 存储,不上传) const HISTORY_KEY = 'hvHistoryV1'; const HISTORY_MAX = 60; function getHistory() { try { const v = storageGet(HISTORY_KEY, []); return Array.isArray(v) ? v : []; } catch (e) { return []; } } function pushHistory(entry) { try { if (!entry || !entry.u || /^(blob|data):/i.test(entry.u)) return; let list = getHistory(); list = list.filter(function (x) { return x && x.u !== entry.u; }); // 同图去重:重新悬停=置顶刷新 if (!entry.id) entry.id = 'h' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); entry.ts = Date.now(); list.unshift(entry); if (list.length > HISTORY_MAX) list.length = HISTORY_MAX; storageSet(HISTORY_KEY, list); } catch (e) { } } function updateHistoryEntry(id, data) { try { const list = getHistory(); const it = list.find(function (x) { return x && x.id === id; }); if (!it) return; Object.assign(it, data, { ts: Date.now() }); const rest = list.filter(function (x) { return x !== it; }); rest.unshift(it); storageSet(HISTORY_KEY, rest.slice(0, HISTORY_MAX)); } catch (e) { } } function removeHistoryItem(id) { try { storageSet(HISTORY_KEY, getHistory().filter(function (x) { return x && x.id !== id; })); } catch (e) { } } function clearHistory() { try { storageSet(HISTORY_KEY, []); } catch (e) { } } // 预览激活时调用:首次记一条;同实例 HD 升级换图后更新同条(不产生重复) // 图集翻页 = 新实例(startPending→show→createInstance),每张各记一条 function recordHistory(inst) { try { if (!inst || !inst.imgEl) return; const u = inst.infoSrc || inst.imgEl.currentSrc || inst.imgEl.src || ''; if (!u || /^(blob|data):/i.test(u)) return; const s = inst.sourceImg; const thumb = s ? (s.currentSrc || s.src || '') : ''; const data = { u: u, thumb: thumb, host: (parseImageInfo(u) || {}).host || '', w: inst.imgEl.naturalWidth || 0, h: inst.imgEl.naturalHeight || 0 }; if (inst.__histId) updateHistoryEntry(inst.__histId, data); else { inst.__histId = 'h' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6); pushHistory(Object.assign({ id: inst.__histId }, data)); } } catch (e) { } } 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 === ''; } // 跨 frame 仲裁:每个 frame 各跑一份脚本,用 GM storage 心跳(ACTIVE 期间续约、超时视为退出) // 选举出唯一活跃预览,避免多个预览叠加;非顶层 frame 不创建 dock 按钮。 const IS_TOP = (function () { try { return window.top === window.self; } catch (e) { return false; } // 跨域访问 window.top 会抛 })(); const ARBITER_KEY = 'image_zoom_active_frames'; const ARBITER_TTL = 1500; const ARBITER_RENEW = 500; // ★ 竞态修复(P1):准入检查与占位是两次独立读写,两个 frame 可能同时通过检查。 // 因此占位后追加一个「复核窗口」:等心跳跨 frame 传播完,再按确定性规则选出唯一赢家, // 输的一方主动让出预览。SETTLE 要略大于一次 storage 往返回传时间。 const ARBITER_SETTLE = 140; // 输掉仲裁后的静默期:期间不再发起新预览,避免两帧反复互抢(乒乓) const ARBITER_COOLDOWN = 800; // 空闲上限:本 frame 多久没收到 mousemove 就放弃仲裁锁,防卡住的 frame 无限续约锁死全局。 const ARBITER_IDLE = 5000; // frameId 必须高熵:两帧若碰撞成同一个 key,心跳会互相覆盖,仲裁直接失效。 // (原实现只有毫秒时间戳 + 5 位 base36 随机,同毫秒创建的兄弟 frame 存在碰撞面) const frameId = 'f' + Date.now().toString(36) + '-' + (function () { try { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { return crypto.randomUUID().slice(0, 8); } } catch (e) { } return Math.random().toString(36).slice(2, 10) + Math.random().toString(36).slice(2, 6); })(); let arbiterTimer = null; let arbiterCooldownUntil = 0; // 本 frame 最近一次收到鼠标事件的时刻(供 ARBITER_IDLE 判定"光标是否还在这里") let arbiterLastMoveAt = 0; window.addEventListener('mousemove', () => { arbiterLastMoveAt = Date.now(); }, { capture: true, passive: true }); // 图集翻页保持:翻页后显示的是光标外的另一张图,保持期内让心跳跳过几何校验,光标移动超阈值即恢复。 let galleryHoldAt = null; window.addEventListener('mousemove', (e) => { if (!galleryHoldAt) return; if (Math.abs(e.clientX - galleryHoldAt.x) > 4 || Math.abs(e.clientY - galleryHoldAt.y) > 4) { galleryHoldAt = null; } }, { capture: true, passive: true }); function readActiveMap() { try { const raw = storageGet(ARBITER_KEY, {}); return (raw && typeof raw === 'object' && !Array.isArray(raw)) ? raw : {}; } catch (e) { return {}; } } function arbiterTouch(active) { try { const map = readActiveMap(); const now = Date.now(); for (const k of Object.keys(map)) { if (now - (map[k] && map[k].t || 0) > ARBITER_TTL) delete map[k]; } if (active) { // t = 最后一次心跳(判活 / TTL 用);c = 首次声明时刻,续约时保持不变。 // 选举必须看 c:否则活跃帧每次续约都会把时间戳刷新成「现在」, // 晚到的竞争者就能靠 frameId 平局规则把它顶掉。 const prev = map[frameId]; map[frameId] = { t: now, c: (prev && prev.c) || now }; } else { delete map[frameId]; } storageSet(ARBITER_KEY, map); } catch (e) { } } // 别的 frame 是否有 ACTIVE 预览(自己的心跳不算) function arbiterOthersActive() { const map = readActiveMap(); const now = Date.now(); return Object.keys(map).some(k => k !== frameId && now - (map[k] && map[k].t || 0) <= ARBITER_TTL); } function arbiterStartRenew() { arbiterTouch(true); if (arbiterTimer) clearInterval(arbiterTimer); arbiterTimer = setInterval(() => { // 后台页必须停止续约:document.hidden 时心跳暂停,若续约照跑该 frame 会永远占着仲裁锁, // 导致其他标签页/frame 无法触发预览。 try { if (document.hidden) { arbiterStopRenew(); return; } // 光标已不在本 frame(长时间无鼠标事件)→ 让出仲裁锁,避免永久占用拖垮全局 if (arbiterLastMoveAt && Date.now() - arbiterLastMoveAt > ARBITER_IDLE) { arbiterStopRenew(); return; } if (zoomFSM && zoomFSM.hasActiveZoom()) arbiterTouch(true); else { arbiterStopRenew(); } } catch (e) { arbiterStopRenew(); } }, ARBITER_RENEW); } function arbiterStopRenew() { if (arbiterTimer) { clearInterval(arbiterTimer); arbiterTimer = null; } arbiterTouch(false); } // ★ 切到后台立即释放仲裁,回到前台时若预览仍在再重新占位。 // 否则本 frame 会在后台一直续约,把其他标签页 / frame 全部锁死。 document.addEventListener('visibilitychange', () => { // zoomFSM 在本模块之后才声明(const),页面加载期间若触发 visibilitychange, // 直接访问会命中暂时性死区(TDZ)抛 ReferenceError。故整体包 try/catch。 try { if (document.hidden) { if (arbiterTimer) arbiterStopRenew(); } else if (zoomFSM && zoomFSM.hasActiveZoom()) { arbiterStartRenew(); } } catch (e) { } }); // 确定性选举:声明时刻最早者胜(平局按 frameId)。只有明确看到别人更早的声明才算输 —— // 自己条目读不到不算输(多 frame 并发读写 GM 存储有延迟)。 function arbiterElectionLost() { try { const map = readActiveMap(); const now = Date.now(); const mine = map[frameId]; if (!mine) { arbiterTouch(true); return false; } // 存储延迟:补一次心跳,不回滚 const myC = mine.c || mine.t || 0; for (const k of Object.keys(map)) { if (k === frameId) continue; const o = map[k]; if (!o || now - (o.t || 0) > ARBITER_TTL) continue; const oC = o.c || o.t || 0; if (oC < myC) return true; if (oC === myC && k < frameId) return true; } return false; } catch (e) { return false; // 读取异常一律不回滚,避免误伤正常预览 } } // 启动新预览的资格:① 不在输掉仲裁后的静默期;② 别的 frame 没有 ACTIVE 预览。 // 本 frame 已 ACTIVE 时不会走到这里(调用点用 hasActiveZoom 挡在前面),切图不受影响。 function arbiterCanAcquire() { if (Date.now() < arbiterCooldownUntil) return false; return !arbiterOthersActive(); } 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 urlPickMode = false; // 统一提示组件:白底 + 品牌蓝圆形图标 + 细边框 + 柔和投影(暗色自动切深底),三个入口共用同一套视觉。 function hvToast(message, opt) { opt = opt || {}; const variant = opt.variant || 'mid'; // mid=屏幕中央 / center=面板中央 / bottom=底部 const type = opt.type || 'ok'; // ok | err | warn const duration = opt.duration || 1600; let el = document.getElementById('hvToastEl'); if (!el) { el = document.createElement('div'); el.id = 'hvToastEl'; el.className = 'hv-toast mid'; el.innerHTML = ''; document.body.appendChild(el); } el.className = 'hv-toast ' + variant + (type === 'ok' ? '' : ' ' + type); el.querySelector('.hv-t-ic').textContent = type === 'ok' ? '✓' : '!'; el.querySelector('.hv-t-tx').textContent = message; void el.offsetWidth; // 强制重排,让连续提示也能重播进入动画 el.classList.add('on'); clearTimeout(el._t); el._t = setTimeout(() => el.classList.remove('on'), duration); } // 面板打开时统一显示在面板中央(面板本身居中,所以屏幕中央即面板中央) function showPanelCenterToast(message, type) { const overlay = document.getElementById('izModalOverlay'); if (!overlay || overlay.style.display !== 'flex') return false; hvToast(message, { variant: 'center', type: type }); return true; } function showToast(message, duration = 2000, type) { if (showPanelCenterToast(message, type)) return; hvToast(message, { variant: 'mid', duration: duration, type: type }); } function showSaveToast(message, type) { if (showPanelCenterToast(message, type)) return; hvToast(message, { variant: 'bottom', type: type }); } // 1. 配置模块 // 键位表默认值:动作名 → 按键列表(event.key 归一化后的小写形式)。新增动作在此加一项并注册实现。 const KEYMAP_DEFAULTS = Object.freeze({ close: ['Escape'], zoomIn: ['+', '='], zoomOut: ['-', '_'], resetZoom: ['0'], saveImage: ['s'], copyLink: ['c'], copyImage: ['C'], // Shift+C:复制图片本体 rotate: ['r'], flip: ['R'], // Shift+R:水平翻转 prevImage: ['ArrowLeft'], nextImage: ['ArrowRight'], fullscreen: ['f'], galleryZip: ['z'] // 图集打包下载(store 模式手写 ZIP,不引第三方库) }); // 面板「键位」区展示用(顺序即展示顺序) const KEYMAP_ACTION_DEFS = [ { key: 'close', name: '关闭预览', hint: '收起当前放大图' }, { key: 'zoomIn', name: '放大', hint: '按步进放大约 8%' }, { key: 'zoomOut', name: '缩小', hint: '按步进缩小约 7.4%' }, { key: 'resetZoom', name: '重置缩放', hint: '回到进入预览时的大小' }, { key: 'saveImage', name: '保存高清图', hint: '优先保存高清原图' }, { key: 'copyLink', name: '复制图片地址', hint: '复制真实来源地址' }, { key: 'copyImage', name: '复制图片', hint: '复制图片本体(跨域可能失败)' }, { key: 'rotate', name: '旋转 90°', hint: '顺时针;转四次回原状' }, { key: 'flip', name: '水平翻转', hint: '再按一次还原' }, { key: 'prevImage', name: '上一张', hint: '图集内往前翻' }, { key: 'nextImage', name: '下一张', hint: '图集内往后翻' }, { key: 'fullscreen', name: '全屏', hint: '再按一次退出' }, { key: 'galleryZip', name: '打包下载图集', hint: '图集模式下按 Z,把整组图打成 ZIP 下载' } ]; // 按键显示名(字母保留大小写,大写即代表 Shift 组合) const KEYMAP_KEY_LABELS = { 'Escape': 'Esc', 'ArrowLeft': '←', 'ArrowRight': '→', 'ArrowUp': '↑', 'ArrowDown': '↓', ' ': '空格', 'Enter': '回车', 'Tab': 'Tab', '+': '+', '=': '=', '-': '-', '_': '_' }; function displayKeyName(k) { if (KEYMAP_KEY_LABELS[k]) return KEYMAP_KEY_LABELS[k]; if (typeof k === 'string' && k.length === 1 && k >= 'A' && k <= 'Z') return 'Shift+' + k; return k; } // 把存储里的键表归一化成合法结构;缺失/非法一律回落到默认值 function normalizeKeymap(raw) { const out = {}; Object.keys(KEYMAP_DEFAULTS).forEach(function (k) { const v = raw && Array.isArray(raw[k]) ? raw[k].filter(function (x) { return typeof x === 'string' && x.length > 0; }) : null; out[k] = (v && v.length) ? Array.from(new Set(v)) : KEYMAP_DEFAULTS[k].slice(); }); return out; } // 键位表是全局配置(按键是肌肉记忆,不随网站变化),只用 image_zoom_keymap_global 一个键。 const KEYMAP_GLOBAL_KEY = 'image_zoom_keymap_global'; function loadGlobalKeymap() { try { const raw = storageGet(KEYMAP_GLOBAL_KEY, null); if (raw && typeof raw === 'object') return normalizeKeymap(raw); } catch (e) { } return null; } function saveGlobalKeymap(km) { try { storageSet(KEYMAP_GLOBAL_KEY, normalizeKeymap(km)); } catch (e) { } } // 用户自定义「图片地址变换规则」:{ id, label, phase:'hd'|'clean', pattern, flags, replace, enabled, scope, domain } // scope='site' 只在本站生效,'global' 全站生效;旧数据无 domain 字段视为 global。 function normalizeUserUrlRules(raw) { const out = []; if (!Array.isArray(raw)) return out; raw.slice(0, 200).forEach(function (r, i) { if (!r || typeof r !== 'object') return; const pattern = typeof r.pattern === 'string' ? r.pattern : ''; if (!pattern) return; const flags = (typeof r.flags === 'string' ? r.flags : '').replace(/[^gimsuy]/g, ''); try { new RegExp(pattern, flags); } catch (e) { return; } // 编译不过直接丢弃 out.push({ id: String(r.id || ('u' + Date.now() + '_' + i)), label: String(r.label || '').slice(0, 60), phase: r.phase === 'clean' ? 'clean' : 'hd', pattern: pattern, flags: flags, replace: typeof r.replace === 'string' ? r.replace : '', enabled: r.enabled !== false, scope: r.scope === 'global' ? 'global' : (r.scope === 'site' ? 'site' : (r.domain ? 'site' : 'global')), domain: typeof r.domain === 'string' ? r.domain.slice(0, 120) : '' }); }); return out; } const defaultConfig = { delay: 800, scale: 2, // ★ 3000(= 配置上限)等效「跟随视口」:预览可用空间 = min(视口-60, 此上限), // 旧默认 1200/980 在宽屏上会把自适应/滚轮预览压得很小(validateConfig 里有存量迁移)。 maxWidth: 3000, maxHeight: 3000, minScale: 1.4, zoomZIndex: 9999, scrollSpeed: 50, smallImgThreshold: 280, smallImgWidth: 500, smallImgHeight: 430, avoidClickConflict: true, blurDismiss: true, wheelZoom: true, videoHoverPreview: true, // ★ 视频悬停预览(悬停视频/视频卡片时静音播放) showImageInfo: true, previewPlacement: 'center', // 显示位置:center=屏幕居中(默认)/ around=原图周围不遮挡 previewTransition: 'dock', // 入场动效:dock 从原图弹出(默认)/ spotlight 从原图绽开 / fade 直接淡入 zoomMode: 'adaptive', minOriginalSize: 51, rulePackAuto: true, // 规则包:启动时自动更新(失败静默降级到内置/缓存) userUrlRules: [], // 用户自定义的图片地址变换规则(面板里增删改) keymap: normalizeKeymap(null) }; 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.videoHoverPreview = typeof v.videoHoverPreview === 'boolean' ? v.videoHoverPreview : true; v.previewPlacement = ['center', 'around'].indexOf(v.previewPlacement) >= 0 ? v.previewPlacement : 'center'; v.previewTransition = ['dock', 'spotlight', 'fade'].indexOf(v.previewTransition) >= 0 ? v.previewTransition : 'dock'; // ★ 旧默认(1200/980)一次性迁移到「跟随视口」(3000/3000):老用户存储里存着旧默认值, // 不迁移的话改默认不生效。用户手动改成其它值的(含恰好 1200 但 maxHeight 非默认)不动。 if (v.maxWidth === 1200 && v.maxHeight === 980) { v.maxWidth = 3000; v.maxHeight = 3000; } v.wheelZoom = typeof v.wheelZoom === 'boolean' ? v.wheelZoom : true; v.showImageInfo = typeof v.showImageInfo === 'boolean' ? v.showImageInfo : true; v.keymap = normalizeKeymap(v.keymap); v.userUrlRules = normalizeUserUrlRules(v.userUrlRules); return v; } function loadConfig() { const saved = storageGet(`image_zoom_config_${currentDomain}`); if (saved) config = { ...defaultConfig, ...validateConfig(saved) }; // ★ 键位表以全局键为准(跨网站共享);全局键还不存在时,把本站旧值迁移上去。 const gkm = loadGlobalKeymap(); if (gkm) config.keymap = gkm; else saveGlobalKeymap(config.keymap); } function saveConfig() { storageSet(`image_zoom_config_${currentDomain}`, config); } function loadState() { isEnabled = storageGet(`image_zoom_enabled_${currentDomain}`) !== false; // ⚠ 不要用「主页禁用」覆盖 isEnabled:那会让面板总开关显示「已关闭」(存储其实是开), // 用户会误以为设置没保存、且下次点开关会把 false 真正写进存储。 // 主页禁用的效果由各功能入口的 `!isEnabled || isHomepageZoomDisabled()` 双检查保证。 } // 主页禁用状态 function isHomepageDisabled() { return storageGet(`image_zoom_homepage_disabled_${currentDomain}`, false); } function isHomepageZoomDisabled() { return isHomepage() && isHomepageDisabled(); } // 配置备份 / 恢复:文件格式 { app, format, scriptVersion, exportedAt, keys: { 键: 值 } }, // 只搬运 image_zoom_ 开头的键(含各站配置)+ B 站音量开关。 const BACKUP_FORMAT_VERSION = 1; const BACKUP_KEY_PREFIX = 'image_zoom_'; const BACKUP_EXTRA_KEYS = ['bilibili_volume_enabled']; function isBackupKey(k) { return typeof k === 'string' && (k.indexOf(BACKUP_KEY_PREFIX) === 0 || BACKUP_EXTRA_KEYS.indexOf(k) >= 0); } function collectBackupKeys() { const keys = new Set(); try { if (typeof GM_listValues === 'function') { (GM_listValues() || []).forEach(k => { if (isBackupKey(k)) keys.add(k); }); } } catch (e) { } // 兜底:个别脚本管理器不提供 GM_listValues 时,至少带上全局键与当前站点的键 if (keys.size === 0) { ['image_zoom_custom_rules', 'image_zoom_intro_seen', 'image_zoom_update_seen', 'image_zoom_dock_top', 'image_zoom_keymap_global'] .concat(BACKUP_EXTRA_KEYS).forEach(k => keys.add(k)); ['config_', 'enabled_', 'homepage_disabled_'].forEach(suffix => { keys.add(BACKUP_KEY_PREFIX + suffix + currentDomain); }); } return Array.from(keys); } function exportConfigToFile() { try { const data = {}; collectBackupKeys().forEach(k => { try { const v = storageGet(k, undefined); if (v !== undefined) data[k] = v; } catch (e) { } }); const payload = { app: 'HoverVista', format: BACKUP_FORMAT_VERSION, scriptVersion: SCRIPT_VERSION, exportedAt: new Date().toISOString(), keys: data }; const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json;charset=utf-8' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 'HoverVista-配置备份-' + new Date().toISOString().slice(0, 10) + '.json'; a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => { a.remove(); URL.revokeObjectURL(url); }, 1000); showToast('已导出 ' + Object.keys(data).length + ' 项配置 📦'); } catch (e) { showToast('导出失败:' + ((e && e.message) || e)); } } // 把备份内容写回存储。成功返回 { ok, skipped },失败返回 null(内部已提示)。 function applyImportedConfig(payload) { const data = (payload && (payload.keys || payload.data)) || null; if (!data || typeof data !== 'object') { showToast('导入失败:配置内容为空'); return null; } let ok = 0, skipped = 0; Object.keys(data).forEach(k => { if (!isBackupKey(k)) { skipped++; return; } try { let v = data[k]; // 站点参数走同一套校验,防止备份里的越界值被原样写回 if (k.indexOf(BACKUP_KEY_PREFIX + 'config_') === 0 && v && typeof v === 'object') v = validateConfig(v); storageSet(k, v); ok++; } catch (e) { skipped++; } }); if (ok === 0) { showToast('导入失败:没有可识别的配置项'); return null; } // 让当前会话立刻用上新配置,而不是等下次刷新 loadConfig(); loadState(); try { bilibiliVolumeModule.setEnabled(storageGet('bilibili_volume_enabled', true)); } catch (e) { } return { ok, skipped }; } function importConfigFromFile(file, onDone) { if (!file) return; if (file.size > 5 * 1024 * 1024) { showToast('导入失败:文件超过 5MB'); return; } const reader = new FileReader(); reader.onerror = () => showToast('导入失败:无法读取文件'); reader.onload = () => { let payload = null; try { payload = JSON.parse(String(reader.result)); } catch (e) { showToast('导入失败:不是有效的 JSON 文件'); return; } if (!payload || payload.app !== 'HoverVista') { showToast('导入失败:这不是 HoverVista 的配置文件'); return; } if (typeof payload.format === 'number' && payload.format > BACKUP_FORMAT_VERSION) { showToast('导入失败:备份来自更新版本,请先升级脚本'); return; } const res = applyImportedConfig(payload); if (res && typeof onDone === 'function') onDone(res); }; reader.readAsText(file); } // 2. 触发资格判定:此刻是否样式可见(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 = 装饰性空覆盖层 → 允许穿透继续找下层图片。 // 🟡 兼容区:网站浮层/菜单兼容规则,改动需重点测试站点。 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)。 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; // ★ 优先 currentSrc:图片同时有 srcset 时,浏览器实际加载并渲染的是 currentSrc, // 而 src 可能是个从未被加载、甚至根本不是图片的兜底地址(用 src 会误判为无效图)。 const src = img.currentSrc || img.src; if (!src || src.trim() === '') return false; // data: URI:允许真实内联图片(data:image/*,含 SVG),仅拦截非图片 data: 与 1×1 占位/跟踪像素。 if (src.startsWith('data:')) { if (!/^data:image\//i.test(src)) return false; // 非图片 data: 一律拦截 if (img.complete && img.naturalWidth <= 1 && img.naturalHeight <= 1) return false; // 1×1 占位/跟踪像素 } else if (src.includes('placeholder')) { return false; } // 真实像素 ≤1×1 一律拦截(1×1 占位/跟踪像素被 CSS 撑大会渲染成空白/纯色块)。 // ⚠ 必须 naturalWidth > 0:naturalWidth===0 是未加载/失败,要留给后面的 GM 抓取救回逻辑。 if (img.complete && img.naturalWidth > 0 && img.naturalWidth <= 1 && img.naturalHeight <= 1) return false; // 真实 带 background-image 作模糊/占位底图时,只要自身有真实 src、已加载且尺寸有效仍视为有效图片。 // 缩略图自身加载失败(防盗链/死链/加载中)不在此拦截,交给 onerror 的 GM 抓取救回。 if (/^(?:javascript:|#)/i.test(src)) return false; const rect = img.getBoundingClientRect(); return !(rect.width < 10 || rect.height < 10); } // 3. 图片处理工具 // URL 变换规则表(表驱动):{ id, name, match, loop, steps: [[正则, 替换串], ...] } // match —— 命中条件(null 表示兜底);loop —— 反复执行整组直到不再变化;steps —— 按序执行。 const URL_RULES = [ { id: 'alicdn', name: '阿里系 CDN 缩略图后缀', match: /alicdn\.com/i, loop: true, steps: [ [/(_!![\w\-.,]+?\.(?:jpg|jpeg|png|webp))_[\w.\-]+$/i, '$1'], [/\.(jpg|jpeg|png|webp)_[\w.]+$/i, '.$1'], [/_\.(webp|jpg|jpeg|png)$/i, ''] ] }, { id: 'generic', name: '通用缩略图后缀', match: null, // 兜底 loop: true, steps: [ [/![\w\-]+$/i, ''], [/_\d+x\d+(q\d+)?\.(jpg|jpeg|png|webp)(\.\w+)?$/i, ''], [/\.(jpg|jpeg|png|webp)_[\w.]+$/i, '.$1'], [/_\.(webp|jpg|jpeg|png)$/i, ''], [/\.webp$/i, '.jpg'] ] } ]; // 高清升级流水线:按顺序全跑(不是命中即停) const HD_UPGRADE_RULES = [ { id: 'sized-dir', name: '尺寸目录 /s{宽}x{高}_', match: null, loop: false, steps: [[/\/s\d+x\d+_/g, '/']] }, { id: 'avif', name: 'avif 后缀回落', match: null, loop: false, steps: [[/\.avif$/i, '']] }, { id: 'remote-thumb', name: 'remote/thumb 缩略路径', match: null, loop: false, steps: [[/\/remote\/thumb\/\d+x\d+\//, '/']] }, { // 京东:请求与预览框上限一致的 CDN 缩放档(s1200x1200,无水印);CDN 不会放大超原图。 id: 'jd-s9999', name: '京东原图 s1200', match: /360buyimg\.com/, loop: false, steps: [[/^(\/\/[^/]+\/|[a-z][a-z0-9+.-]*:\/\/[^/]+\/).*?(jfs\/|g\d+\/)/i, '$1n1/s1200x1200_$2']] } ]; // 执行单条规则(含 loop 收敛)。lastRewrite 记录最近一次「规则真的改写了地址」,供面板展示是否生效。 let lastRuleHit = null; function applyUrlRule(url, rule, record) { let u = url, prev; do { prev = u; for (let i = 0; i < rule.steps.length; i++) { u = u.replace(rule.steps[i][0], rule.steps[i][1]); } } while (rule.loop && u !== prev); if (record && u !== url) { lastRuleHit = { id: rule.id, name: rule.name, from: url, to: u, at: Date.now() }; } return u; } // 命中即停:取第一条 match 为 null 或能命中的规则(URL_RULES 语义) function applyFirstMatchedRule(url, rules, record) { for (let i = 0; i < rules.length; i++) { if (!rules[i].match || rules[i].match.test(url)) return applyUrlRule(url, rules[i], record); } return url; } // 顺序全跑:每条规则各自判断是否命中(HD_UPGRADE_RULES 语义) function applyUrlPipeline(url, rules, record) { let u = url; for (let i = 0; i < rules.length; i++) { const r = rules[i]; if (r.match && !r.match.test(u)) continue; u = applyUrlRule(u, r, record); } return u; } // ★ 实际生效的规则表:默认=内置;规则包加载后替换为「规则包规则(站专属,优先)+ 内置规则」 let activeHdRules = HD_UPGRADE_RULES; let activeCleanRules = URL_RULES; // 高清升级入口:等价于旧的「s尺寸目录 → 去 avif → remote/thumb」三步链 function upgradeImgUrl(url) { if (!url) return url; return applyUrlPipeline(url, activeHdRules, true); // record=true:真实解析才记「最近命中」 } // 防盗链绕过:直连失败时用 GM_xmlhttpRequest 以脚本身份抓取(不受页面 Referer 限制), // 转 blob URL 交给 显示;带 LRU 缓存与静默回退(失败保持源图)。 const BLOB_URL_CACHE = new Map(); // 原始 URL → blob URL const BLOB_CACHE_MAX = 24; function releaseBlobUrl(blobUrl) { if (!blobUrl) return; try { URL.revokeObjectURL(blobUrl); } catch (e) { } for (const [k, v] of BLOB_URL_CACHE) { if (v === blobUrl) { BLOB_URL_CACHE.delete(k); break; } } } function gmFetchBlobUrl(url) { return new Promise((resolve, reject) => { if (!url) return reject(new Error('empty url')); const hit = BLOB_URL_CACHE.get(url); if (hit) return resolve(hit); if (typeof GM_xmlhttpRequest !== 'function') return reject(new Error('GM_xmlhttpRequest unavailable')); GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', timeout: 15000, onload: (res) => { try { if (res.status >= 400 || !res.response) return reject(new Error('http ' + res.status)); const blobUrl = URL.createObjectURL(res.response); if (BLOB_URL_CACHE.size >= BLOB_CACHE_MAX) { const oldestKey = BLOB_URL_CACHE.keys().next().value; const oldestVal = BLOB_URL_CACHE.get(oldestKey); try { URL.revokeObjectURL(oldestVal); } catch (e) { } BLOB_URL_CACHE.delete(oldestKey); } BLOB_URL_CACHE.set(url, blobUrl); resolve(blobUrl); } catch (e) { reject(e); } }, onerror: () => reject(new Error('gm fetch error')), ontimeout: () => reject(new Error('gm fetch timeout')) }); }); } // 智能高清升级器:生成多个候选 → 并行探测真实像素 → 选「够用且最小」的那张(都够不上用最大)。 const HD_MAX_CANDIDATES = 4; function sizeHintOf(url) { const m = url.match(/\/s(\d+)x\d+_/i) || url.match(/[?&](?:w|width)=(\d+)/i) || url.match(/_(\d+)w\./i); if (!m) return 0; const n = parseInt(m[1], 10); return isNaN(n) ? 0 : n; } // 候选分两类: // primary —— 规则链各步的结果(人工/规则包策展出来的地址) // variants —— 从 URL 里的尺寸指令推出来的更大档位(给「没有规则」或「规则结果不可用」时兜底) function buildHdCandidates(url) { const primary = [], variants = []; const pushTo = (arr) => (u) => { if (u && u !== url && arr.indexOf(u) < 0 && arr.length < HD_MAX_CANDIDATES) arr.push(u); }; const pushP = pushTo(primary), pushV = pushTo(variants); let u = url; for (let i = 0; i < activeHdRules.length; i++) { const r = activeHdRules[i]; if (r.match && !r.match.test(u)) continue; let nu = u; try { nu = applyUrlRule(u, r, false); } catch (e) { continue; } if (nu !== u) { u = nu; pushP(u); } } const hint = sizeHintOf(url); if (hint > 0) { [1200, 1600, 2400].forEach((t) => { if (t <= hint) return; const v = url.replace(/\/s\d+x\d+_/i, '/s' + t + 'x' + t + '_'); if (v !== url) pushV(v); }); } return { primary: primary, variants: variants }; } // 并行探测候选:返回 [{url, w, h}](失败的丢弃)。带整体超时,避免慢图拖住预览。 function probeCandidates(list, timeoutMs) { return new Promise((resolve) => { const done = []; let left = list.length; const finish = () => resolve(done.filter(Boolean)); if (!left) return finish(); const timer = setTimeout(() => { if (left > 0) { left = 0; finish(); } }, timeoutMs || 3500); list.forEach((u, i) => { const im = new Image(); const settle = (ok) => { if (left <= 0) return; if (ok) done[i] = { url: u, w: im.naturalWidth, h: im.naturalHeight }; left--; if (left <= 0) { clearTimeout(timer); finish(); } }; im.onload = () => settle(im.naturalWidth > 0 && im.naturalHeight > 0); im.onerror = () => settle(false); im.src = u; }); }); } // 择优选:优先「≥ 需求像素里最小的」,否则取最大的 function pickBestCandidate(loaded, needMax) { if (!loaded || !loaded.length) return null; const need = Math.max(1, Number(needMax) || 0); const enough = loaded.filter((c) => Math.max(c.w, c.h) >= need) .sort((a, b) => Math.max(a.w, a.h) - Math.max(b.w, b.h)); if (enough.length) return enough[0]; return loaded.slice().sort((a, b) => (b.w * b.h) - (a.w * a.h))[0]; } 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) { // 与旧实现一致:先 trim 再去掉包裹引号,然后交给规则表(命中即停) const u = url.trim().replace(/^['"]|['"]$/g, ''); return applyFirstMatchedRule(u, activeCleanRules); } // 规则包:远端 raw.githubusercontent.com//main/rules/index.json + rules/.json。 // 只提供「正则 → 替换」数据、不执行代码;拉取/JSON/正则失败一律保留现状。 // 供应链加固:index.json 带逐域 SHA-256 + 整包 ECDSA 签名(公钥内嵌),任一失败整包拒绝并沿用旧缓存。 const RULE_PACK_URL = 'https://raw.githubusercontent.com/YDGG123/hover-image-zoom/main/rules/index.json'; const RULE_PACK_URL_PREFIX = 'https://raw.githubusercontent.com/YDGG123/hover-image-zoom/main/rules/'; const RULE_PACK_KEY = 'image_zoom_rule_pack_current'; const RULE_PACK_PREV_KEY = 'image_zoom_rule_pack_prev'; const RULE_PACK_META_KEY = 'image_zoom_rule_pack_meta'; const RULE_PACK_MAX_STEPS = 60; // 单条规则最多几步,防止恶意/失控数据 const RULE_PACK_TIMEOUT = 12000; // ★ 防供应链护栏(补强):步数上限挡不住「单个巨正则」的 ReDoS / 内存攻击, // 所以再加 pattern / replace 长度上限与单域规则条数上限——超限整条/整段丢弃。 const RULE_PACK_MAX_PATTERN = 1000; // 单个正则 pattern 最大长度 const RULE_PACK_MAX_REPLACE = 500; // 单个替换串最大长度 const RULE_PACK_MAX_PER_DOMAIN = 50; // 单域 clean / hd 各自最多条数 /* HVSIGN-BEGIN */ // 规则包签名校验(ECDSA P-256 + SHA-256,WebCrypto):先验整包签名,再逐文件比对哈希, // 任一步失败 → 整包拒绝并沿用旧缓存。私钥只在维护者本地,仓库里只有公钥。 const RULE_PACK_PUBKEY_SPKI = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEMQjaMjNcAdjyvuhVpOzd4n6cyfKpuufMh+aMiAVN2m0MC/Z7072Hh0RIXcgzPZ4rAVm9bn+Cg9dN9nIdIq8wHQ=='; function rulePackSha256Hex(str) { return crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)).then(function (buf) { const arr = new Uint8Array(buf), out = []; for (let i = 0; i < arr.length; i++) out.push(arr[i].toString(16).padStart(2, '0')); return out.join(''); }); } // DER(0x30…) → P1363 裸 r||s(64 字节);已是裸格式则原样返回 function rulePackSigToRaw(sigBytes) { if (sigBytes.length === 64) return sigBytes; if (sigBytes[0] !== 0x30) throw new Error('sig-format'); let i = 2; if (sigBytes[1] & 0x80) i = 2 + (sigBytes[1] & 0x7f); function nextInt() { if (sigBytes[i] !== 0x02) throw new Error('sig-format'); const len = sigBytes[i + 1]; const v = sigBytes.subarray(i + 2, i + 2 + len); i += 2 + len; return v; } const r = nextInt(), s = nextInt(); const out = new Uint8Array(64); out.set(r.length > 32 ? r.subarray(r.length - 32) : r, 32 - Math.min(32, r.length)); out.set(s.length > 32 ? s.subarray(s.length - 32) : s, 64 - Math.min(32, s.length)); return out; } function b64ToBytes(b64) { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } // index 整体验签;resolve 即通过,reject 带 sig-* 原因 function verifyRulePackIndexSignature(index) { if (typeof crypto === 'undefined' || !crypto.subtle) return Promise.reject(new Error('sig-unsupported')); if (!index || typeof index.signature !== 'string' || !index.signature) return Promise.reject(new Error('sig-missing')); if (index.alg !== 'ECDSA-P256-SHA256') return Promise.reject(new Error('sig-alg')); const payload = Object.assign({}, index); delete payload.signature; const data = new TextEncoder().encode(JSON.stringify(payload)); let spki, sig; try { spki = b64ToBytes(RULE_PACK_PUBKEY_SPKI); sig = b64ToBytes(index.signature); } catch (e) { return Promise.reject(new Error('sig-format')); } return crypto.subtle.importKey('spki', spki.buffer, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['verify']) .then(function (key) { let rawSig; try { rawSig = rulePackSigToRaw(sig); } catch (e) { throw new Error('sig-format'); } return crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, key, rawSig, data) .then(function (ok) { if (!ok) throw new Error('sig-invalid'); }); }); } // 单个域文件哈希校验(与 index.hashes[domain] 比对) function verifyRulePackFileHash(rawText, domain, index) { const hashes = index && index.hashes; if (!hashes || typeof hashes[domain] !== 'string') return Promise.reject(new Error('hash-missing:' + domain)); return rulePackSha256Hex(rawText).then(function (hex) { if (hex !== hashes[domain].toLowerCase()) throw new Error('hash-mismatch:' + domain); }); } /* HVSIGN-END */ function packStepToInternal(step) { // 远端格式:[pattern, flags, replace] if (!Array.isArray(step) || typeof step[0] !== 'string') return null; if (step[0].length > RULE_PACK_MAX_PATTERN) return null; const flags = typeof step[1] === 'string' ? step[1].replace(/[^gimsuy]/g, '') : ''; const replace = typeof step[2] === 'string' ? step[2] : ''; if (replace.length > RULE_PACK_MAX_REPLACE) return null; try { return [new RegExp(step[0], flags), replace]; } catch (e) { return null; } } // 把远端的一条规则转成内部格式;任何非法字段都丢弃该规则 function packRuleToInternal(raw) { if (!raw || typeof raw !== 'object') return null; const rawSteps = Array.isArray(raw.steps) ? raw.steps.slice(0, RULE_PACK_MAX_STEPS) : []; const steps = rawSteps.map(packStepToInternal).filter(Boolean); if (!steps.length) return null; let match = null; if (typeof raw.match === 'string' && raw.match) { try { match = new RegExp(raw.match, 'i'); } catch (e) { match = null; } } return { id: 'pack:' + (raw.id || 'rule'), name: raw.name || '规则包规则', match, loop: raw.loop === true, steps }; } // 域名是否匹配(后缀匹配:sub.bilibili.com 命中 bilibili.com) // 兼容品牌式键名(无 TLD,如 jd / pinterest):按整段标签包含,命中 www.jd.com / pinterest.com function packDomainMatches(domain, candidate) { if (!candidate || !domain) return false; if (domain === candidate || domain.endsWith('.' + candidate)) return true; return candidate.length >= 2 && ('.' + domain + '.').indexOf('.' + candidate + '.') >= 0; } // 重新计算「实际生效的规则表」:用户规则 > 站专属规则包规则 > 内置规则 function rebuildEffectiveRules() { const domain = currentDomain; const pack = rulePackState.current; let clean = URL_RULES.slice(); let hd = HD_UPGRADE_RULES.slice(); if (pack && Array.isArray(pack.domains)) { for (let i = 0; i < pack.domains.length; i++) { const entry = pack.domains[i]; if (!entry || !packDomainMatches(domain, entry.domain)) continue; const extraClean = (entry.clean || []).map(packRuleToInternal).filter(Boolean); const extraHd = (entry.hd || []).map(packRuleToInternal).filter(Boolean); clean = extraClean.concat(clean); // 站专属命中即停 → 放前面 hd = extraHd.concat(hd); // 顺序全跑 → 站专属先跑 } } // ★ 用户自定义规则优先级最高(用户明确为自己这台机器加的,应当先于一切生效) const userRules = (config && config.userUrlRules) || []; const userClean = [], userHd = []; userRules.forEach(function (r) { if (r.enabled === false) return; // ★ 作用域:scope='site' 只在记录的 domain 生效(面板默认「仅本站」);'global' 才对所有站点生效 if (r.scope === 'site' && r.domain && !packDomainMatches(currentDomain, r.domain)) return; const internal = packRuleToInternal({ id: r.id, name: r.label || '自定义规则', loop: false, steps: [[r.pattern, r.flags, r.replace]] }); if (!internal) return; (r.phase === 'clean' ? userClean : userHd).push(internal); }); clean = userClean.concat(clean); hd = userHd.concat(hd); activeCleanRules = clean; activeHdRules = hd; return { clean: clean.length, hd: hd.length, userClean: userClean.length, userHd: userHd.length }; } const rulePackState = { current: null, // { version, updatedAt, domains: [...] } meta: null, // { fetchedAt, source, version, error } loading: false }; function loadRulePackFromStorage() { rulePackState.current = storageGet(RULE_PACK_KEY, null) || null; rulePackState.meta = storageGet(RULE_PACK_META_KEY, null) || null; rebuildEffectiveRules(); } // 拉取(GM_xmlhttpRequest)→ 解析 → 校验 → 落盘 → 生效 // 说明:不使用 fetch,因为用户脚本运行环境里 GM_xmlhttpRequest 才能正确处理跨域与 @connect function fetchRulePackText(url) { return new Promise(function (resolve, reject) { try { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('no-gm-xhr')); return; } GM_xmlhttpRequest({ method: 'GET', url: url, timeout: RULE_PACK_TIMEOUT, headers: { 'Accept': 'application/json' }, onload: function (res) { if (res.status >= 200 && res.status < 300 && typeof res.responseText === 'string') resolve(res.responseText); else reject(new Error('http-' + res.status)); }, ontimeout: function () { reject(new Error('timeout')); }, onerror: function () { reject(new Error('network')); } }); } catch (e) { reject(e); } }); } function updateRulePack(manual) { if (rulePackState.loading) return Promise.resolve({ ok: false, reason: 'busy' }); rulePackState.loading = true; return fetchRulePackText(RULE_PACK_URL + '?t=' + Date.now()).then(async function (text) { let index; try { index = JSON.parse(text); } catch (e) { throw new Error('index-json'); } if (!index || !Array.isArray(index.domains)) throw new Error('index-shape'); // ★ 供应链加固第一步:index 整体验签,签名不符 → 整包拒绝(沿用旧缓存) await verifyRulePackIndexSignature(index); const version = Number(index.version) || 0; const prevMeta = rulePackState.meta || {}; // 只拉取「当前域名」相关的规则文件,避免整包下载 const wanted = index.domains.filter(function (d) { return typeof d === 'string' && packDomainMatches(currentDomain, d); }); // ★「版本相同→跳过」的前提:缓存里已有【当前站点】匹配的规则。 // 缓存是全局的、domains 是「当时那个站」的——否则 A 站更新后访问 B 站会被短路, // B 永远拉不到自己的规则;一次网络抖动也会把空 domains 固化进缓存。 const cachedDomains = (rulePackState.current && rulePackState.current.domains) || []; const cachedMatch = cachedDomains.some(function (e) { return e && e.domain && packDomainMatches(currentDomain, e.domain); }); if (!manual && version && prevMeta.version === version && rulePackState.current && (wanted.length === 0 || cachedMatch)) { rulePackState.loading = false; return { ok: true, unchanged: true, version: version }; } return Promise.all(wanted.map(function (d) { return fetchRulePackText(RULE_PACK_URL_PREFIX + encodeURIComponent(d) + '.json') .then(function (t2) { const o = JSON.parse(t2); return (o && typeof o === 'object') ? { raw: t2, obj: o } : null; }) .catch(function () { return null; }); })).then(async function (files) { // ★ 供应链加固第二步:逐域文件哈希校验(比对 index.hashes,防单文件被篡改/投毒) for (let fi = 0; fi < files.length; fi++) { if (!files[fi]) continue; // 拉取失败的照旧跳过 await verifyRulePackFileHash(files[fi].raw, wanted[fi], index); } const domains = files.filter(Boolean).map(function (f) { const o = f.obj; return { domain: String(o.domain || ''), label: String(o.label || ''), clean: Array.isArray(o.clean) ? o.clean.slice(0, RULE_PACK_MAX_PER_DOMAIN) : [], hd: Array.isArray(o.hd) ? o.hd.slice(0, RULE_PACK_MAX_PER_DOMAIN) : [] }; }).filter(function (e) { return e.domain; }); const next = { version: version, updatedAt: String(index.updatedAt || ''), domains: domains }; // ★ 回滚点:把「当前」挪到「上一版」再覆盖 const old = rulePackState.current; if (old) storageSet(RULE_PACK_PREV_KEY, old); storageSet(RULE_PACK_KEY, next); const meta = { fetchedAt: Date.now(), source: 'remote', version: version, at: next.updatedAt, domains: domains.map(function (d) { return d.domain; }), verified: true }; storageSet(RULE_PACK_META_KEY, meta); rulePackState.current = next; rulePackState.meta = meta; const eff = rebuildEffectiveRules(); rulePackState.loading = false; return { ok: true, version: version, domains: meta.domains, clean: eff.clean, hd: eff.hd }; }); }).catch(function (e) { rulePackState.loading = false; const meta = Object.assign({}, rulePackState.meta || {}, { lastError: String(e && e.message || e), lastErrorAt: Date.now() }); rulePackState.meta = meta; storageSet(RULE_PACK_META_KEY, meta); return { ok: false, reason: String(e && e.message || e) }; }); } // 回滚到上一版规则包 function rollbackRulePack() { const prev = storageGet(RULE_PACK_PREV_KEY, null); if (!prev) return { ok: false, reason: 'no-prev' }; const cur = rulePackState.current; storageSet(RULE_PACK_KEY, prev); if (cur) storageSet(RULE_PACK_PREV_KEY, cur); rulePackState.current = prev; const meta = Object.assign({}, rulePackState.meta || {}, { source: 'rollback', version: prev.version || 0, rolledBackAt: Date.now() }); rulePackState.meta = meta; storageSet(RULE_PACK_META_KEY, meta); rebuildEffectiveRules(); return { ok: true, version: prev.version || 0 }; } // 本函数为唯一实现(含滚轮保护分支)。 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) { // 跨域图 drawImage 后 canvas 被 taint(getImageData 抛 SecurityError), // 改用 GM_xmlhttpRequest 抓成同源 blob 再读像素裁剪(用户脚本特权,绕过 CORS)。 cropBlackBarsViaGm(imgEl, w, h); return; } performBlackCrop(imgEl, imgEl, w, h, data); } catch (e) { } } // 黑边裁剪主体:srcImg 必须是「同源」图源(同源时是 imgEl,跨域时是 GM proxy), // 否则 out.drawImage 会再次污染 canvas、toBlob 失败。 function performBlackCrop(imgEl, srcImg, w, h, data) { try { 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; // 黑边亮度阈值 36:容忍「不纯黑的黑边」(JPEG 压缩 / 深灰边框,RGB 25~40),避免该侧裁不掉。 const threshold = 36; const isContent = (i) => data[i + 3] > 10 && (data[i] > threshold || data[i + 1] > threshold || data[i + 2] > threshold); // 判「整行/整列是否为黑边」:内容像素占比 < 15%(BAR_RATIO)或平均亮度 < 42(BAR_LUMA)即算黑边。 const BAR_RATIO = 0.15; const BAR_LUMA = 42; // 平均亮度(0~255)低于此值 → 直接判为黑边 const rowIsBar = (y0) => { let cnt = 0, sum = 0; for (let x0 = 0; x0 < w; x0++) { const i = (y0 * w + x0) * 4; if (isContent(i)) cnt++; sum += data[i] + data[i + 1] + data[i + 2]; } return cnt < w * BAR_RATIO || (sum / (w * 3)) < BAR_LUMA; }; // 桥接扫描:允许跳过黑边内部孤立的内容行/列(水印·文字·噪点),连续非黑边行 ≤ maxGap 时可跳过, // 超过则判定进入内容区、回退到最后一个黑边行。 const MAX_GAP_R = Math.max(3, Math.round(h * 0.03)); const MAX_GAP_C = Math.max(3, Math.round(w * 0.03)); const scanEdge = (start, dir, len, isBarFn, maxGap) => { let pos = start, lastBar = start - dir, gap = 0; while (pos >= 0 && pos < len) { if (isBarFn(pos)) { lastBar = pos; gap = 0; } else { gap++; if (gap > maxGap) break; } pos += dir; } return lastBar; // 最后一个黑边行/列的位置(无则 start-dir) }; // 阶段①:先裁上下黑边(行) let top = scanEdge(0, 1, h, rowIsBar, MAX_GAP_R) + 1; if (top >= h) return; let bottom = scanEdge(h - 1, -1, h, rowIsBar, MAX_GAP_R) - 1; if (bottom < top) return; // 阶段②:再裁左右黑边(列)——只在已确定的 [top,bottom] 内容行范围内判定, // 否则上下各占大半黑边的图会把每一列都拉到黑边水平、整幅被判成黑边。 const innerH = bottom - top + 1; const colIsBar = (x0) => { let cnt = 0, sum = 0; for (let y0 = top; y0 <= bottom; y0++) { const i = (y0 * w + x0) * 4; if (isContent(i)) cnt++; sum += data[i] + data[i + 1] + data[i + 2]; } return cnt < innerH * BAR_RATIO || (sum / (innerH * 3)) < BAR_LUMA; }; let left = scanEdge(0, 1, w, colIsBar, MAX_GAP_C) + 1; let right = scanEdge(w - 1, -1, w, colIsBar, MAX_GAP_C) - 1; // ★ 单边最多裁 45%(旧值 25% 会把「超过 1/4 的大黑边」裁不干净,只裁掉一部分) const CROP_CAP = 0.45; top = Math.min(top, Math.floor(h * CROP_CAP)); bottom = Math.max(bottom, h - 1 - Math.floor(h * CROP_CAP)); left = Math.min(left, Math.floor(w * CROP_CAP)); right = Math.max(right, w - 1 - Math.floor(w * CROP_CAP)); 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(srcImg, 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 回调会重置用户刚滚轮放大的尺寸(竖图跳回最小)。 // 同时让容器始终与实际图片尺寸一致,避免透明大框。 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; // 必须按「裁剪后」比例 cw/ch 重算容器与图片尺寸,并等比收进可用空间(不超视口,信息栏高度一并预留)。 const scale = Math.max(0.01, visualW / Math.max(1, w)); const availW = Math.max(160, window.innerWidth - 60); const availH = Math.max(160, window.innerHeight - 60 - capbarReserve()); let nw = Math.max(1, Math.round(cw * scale)); let nh = Math.max(1, Math.round(ch * scale)); const kFit = Math.min(1, availW / nw, availH / nh); if (kFit < 1) { nw = Math.max(1, Math.round(nw * kFit)); nh = Math.max(1, Math.round(nh * kFit)); } activeInst.zoomBaseW = Math.max(1, nw / currentZoom); activeInst.zoomBaseH = Math.max(1, nh / 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) { } } // ★ 跨域图的黑边裁剪:GM_xmlhttpRequest 抓成同源 blob → 加载 proxy 图 → 读像素 → 走同一裁剪主体。 // 只对 http(s) 跨域图启用;data:/blob: 本就同源(不会触发污染分支),无需处理。 // GM 抓取失败(断网/防盗链且 GM 也被拒)则放弃裁剪,保留原图,不影响预览本身。 function cropBlackBarsViaGm(imgEl, w, h) { try { if (imgEl.dataset.zoomCropGm) return; // 防重入(同一张图只抓一次) const src = imgEl.src; if (!/^https?:/i.test(src)) return; imgEl.dataset.zoomCropGm = '1'; gmFetchBlobUrl(src).then((blobUrl) => { const proxy = new Image(); proxy.onload = () => { try { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; const ctx = canvas.getContext('2d', { willReadFrequently: true }); ctx.drawImage(proxy, 0, 0); const data = ctx.getImageData(0, 0, w, h).data; // blob 同源,不污染 performBlackCrop(imgEl, proxy, w, h, data); } catch (e) { } }; proxy.onerror = () => { }; proxy.src = blobUrl; }).catch(() => { }); } 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),统一归一为「非空串 或 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:12px; image-rendering:auto; box-shadow:0 10px 34px rgba(0,0,0,.30),0 3px 10px rgba(0,0,0,.20),0 0 0 1px rgba(255,255,255,.10);`; 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.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, #izHelpModal, .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); } // 图片登记处理器(不改 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. 图片登记 // 图片信息浮层(尺寸 · 格式 · 来源域名 · 是否已升级到原图):只从 inst.infoSrc 取 URL, // 不取 imgEl.src(黑边裁剪后会换成 blob:,域名与格式会丢失)。 function parseImageInfo(url) { try { const u = new URL(url, window.location.href); let host = ''; try { host = String(u.hostname || '').replace(/^www\./, ''); } catch (e) { } let ext = ''; if (u.protocol === 'data:') ext = 'DATA'; else { const m = String(u.pathname || '').match(/\.([a-zA-Z0-9]{2,5})$/); if (m) ext = m[1].toUpperCase(); } return { host, ext }; } catch (e) { return { host: '', ext: '' }; } } // 图说(caption):优先 alt,其次 title,再次最近的 figcaption。只认页面上的源图。 function extractCaption(inst) { try { const s = inst && inst.sourceImg; if (!s || !s.getAttribute) return ''; let t = (s.getAttribute('alt') || '').trim() || (s.getAttribute('title') || '').trim(); if (!t) { const fig = s.closest ? s.closest('figure') : null; const fc = fig ? fig.querySelector('figcaption') : null; if (fc) t = (fc.textContent || '').trim(); } if (!t) return ''; t = t.replace(/\s+/g, ' '); return t.length > 120 ? t.slice(0, 119) + '…' : t; } catch (e) { return ''; } } function updateImageInfo(inst) { try { const box = inst && inst.container; if (!box) return; const existing = box.querySelector('#izImageInfo'); const barExisting = box.querySelector('.hv-capbar'); if (!config.showImageInfo) { if (barExisting) barExisting.remove(); // 信息栏整块移除(外壳保留) if (existing) existing.remove(); const capGone = box.querySelector('#izImageCaption'); // 图说随开关一起隐藏 if (capGone) capGone.remove(); return; } const imgEl = inst.imgEl; if (!imgEl) return; const w = imgEl.naturalWidth, h = imgEl.naturalHeight; const src = inst.infoSrc || imgEl.currentSrc || imgEl.src || ''; // 空地址不能交给 new URL():它会被解析成当前页面地址,凭空多出一个“来源域名” const info = src ? parseImageInfo(src) : { host: '', ext: '' }; // 图片名称:URL 末段文件名(去掉扩展名与查询串,解码后截断) const fileName = (function () { try { const last = String(src).split('?')[0].split('#')[0].split('/').pop() || ''; let n = decodeURIComponent(last).replace(/\.[A-Za-z0-9]{2,5}$/, ''); n = n.replace(/[_-]+/g, ' ').trim(); return n.length > 46 ? n.slice(0, 45) + '…' : n; } catch (e) { return ''; } })(); const parts = []; if (w > 0 && h > 0) parts.push(w + '×' + h); if (info.ext) parts.push(info.ext); if (info.host) parts.push(info.host); if (inst.usingHiRes) parts.push('原图'); // ★ 诊断:显示「源缩略图尺寸 → 实际加载尺寸」与渲染策略(截图排障用,尺寸不同才显示) try { const sw = inst.sourceImg ? Number(inst.sourceImg.naturalWidth) || 0 : 0; const sh = inst.sourceImg ? Number(inst.sourceImg.naturalHeight) || 0 : 0; if (sw > 0 && sh > 0 && (sw !== w || sh !== h)) parts.push('源' + sw + '×' + sh); if (HV_DEBUG && inst.hdDiag) parts.push(inst.hdDiag); } catch (e) { } // 图集位置(仅在确实有多张时显示,如「3/9」) if (inst.galleryList && inst.galleryList.length >= 2) { parts.push((inst.galleryIndex + 1) + '/' + inst.galleryList.length); } if (parts.length === 0 && !fileName) return; // ★ 信息栏(capbar):位于外壳下方,与图片分离,不遮挡画面 let bar = barExisting; if (!bar) { bar = document.createElement('div'); bar.className = 'hv-capbar'; bar.setAttribute('aria-hidden', 'true'); box.appendChild(bar); } // 名称(主标题,最左) let nameEl = bar.querySelector('.hv-capname'); if (fileName) { if (!nameEl) { nameEl = document.createElement('span'); nameEl.className = 'hv-capname'; bar.insertBefore(nameEl, bar.firstChild); } if (nameEl.textContent !== fileName) nameEl.textContent = fileName; } else if (nameEl) { nameEl.remove(); } let el = existing; if (!el) { el = document.createElement('div'); el.id = 'izImageInfo'; bar.appendChild(el); } else if (el.parentNode !== bar) { bar.appendChild(el); } el.style.cssText = ''; // 样式全部交给 .hv-capbar / #izImageInfo 的 CSS 规则 const text = parts.join(' · '); if (el.textContent !== text) el.textContent = text; // 与放大图淡入同步:容器完全显示后才亮出,避免加载阶段闪一下 if (box.style.opacity === '1') { el.style.opacity = '1'; if (bar) bar.style.opacity = box.classList.contains('hv-nocap') ? '0' : '1'; } updateCaption(inst, box, bar); } catch (e) { } } // 图说:显示为信息栏的第二行(随「显示图片信息」开关一起开关) function updateCaption(inst, box, bar) { try { const target = bar || box; const cap = extractCaption(inst); let capEl = box.querySelector('#izImageCaption'); if (!cap) { if (capEl) capEl.remove(); return; } if (!capEl) { capEl = document.createElement('div'); capEl.id = 'izImageCaption'; capEl.setAttribute('aria-hidden', 'true'); capEl.className = 'hv-capcap'; target.appendChild(capEl); } else if (capEl.parentNode !== target) { target.appendChild(capEl); } if (capEl.textContent !== cap) capEl.textContent = cap; if (box.style.opacity === '1') capEl.style.opacity = box.classList.contains('hv-nocap') ? '0' : '1'; } catch (e) { } } // 5. ★ 核心:单实例状态机 zoomFSM // 6. ★ 全局事件流 + 停稳裁决器(🔴 核心保护区: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 (urlPickMode) { zoomFSM.dispatch('HOVER_NONE', { x, y, force: true }); return; } 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, #izHelpModal')) { zoomFSM.dispatch('HOVER_NONE', { x, y }); return; } if (t && t.closest && t.closest('.image-zoom-container')) return; // ★ 视频悬停预览优先:命中视频/视频卡片时,图片悬停逻辑让位(避免缩略图被顶出来) if (videoPreviewModule.handleHover(x, y, t)) { zoomFSM.dispatch('HOVER_NONE', { x, y }); return; } // 跨 frame 仲裁:别的 frame 有 ACTIVE 预览时本 frame 让位(只拦新触发,不打断已有预览)。 // 自己已 ACTIVE 时 arbiterCanAcquire 仍为 true(自己的心跳不算「别人」),切图不受影响。 if (!zoomFSM.hasActiveZoom() && !arbiterCanAcquire()) { zoomFSM.dispatch('HOVER_NONE', { x, y }); 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 // 保存图片:文件名模板 {标题}_{宽}x{高}.{扩展名} const SAFE_NAME_MAX = 100; // 去掉文件系统不接受的字符、压缩空白、限制长度 function sanitizeFileName(s) { return String(s || '') .replace(/[\\/:*?"<>|\u0000-\u001f]/g, ' ') .replace(/[\r\n\t]+/g, ' ') .replace(/\s+/g, ' ') .replace(/^[.\s]+|[.\s]+$/g, '') .slice(0, SAFE_NAME_MAX) .trim(); } // 从 URL 推断扩展名;data: URL 走 MIME function guessExt(url) { const u = String(url || ''); const dm = u.match(/^data:([a-z0-9.+-]+\/[a-z0-9.+-]+)/i); if (dm) { const sub = (dm[1].split('/')[1] || '').split('+')[0] || 'jpg'; return sub.replace('jpeg', 'jpg').toLowerCase(); } try { const p = new URL(u, location.href).pathname; const m = p.match(/\.([a-z0-9]{2,5})$/i); if (m) return m[1].toLowerCase().replace('jpeg', 'jpg'); } catch (e) { } return 'jpg'; } // 取「被保存的那张图」的像素尺寸:优先预览图(高清升级后即高清尺寸), // 但黑边裁剪会把 imgEl.src 换成 blob:,那时尺寸不代表原始文件 → 回落到源图尺寸。 function savedImageSize(inst) { const z = inst && inst.imgEl, s = inst && inst.sourceImg; const zSrc = (z && z.src) || ''; if (z && z.naturalWidth > 0 && zSrc.indexOf('blob:') !== 0) { return { w: z.naturalWidth, h: z.naturalHeight }; } if (s && s.naturalWidth > 0) return { w: s.naturalWidth, h: s.naturalHeight }; return { w: 0, h: 0 }; } // ---------------- 图集 ZIP 打包(手写最小实现:store 模式 + CRC32,不引第三方库) ---------------- // 说明:图片已是压缩格式(jpg/png),ZIP store(不二次压缩)体积几乎无差,还能省 ~90KB 的 jszip。 const ZIP_CRC_TABLE = (function () { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c >>> 0; } return t; })(); function zipCrc32(bytes) { let c = 0xFFFFFFFF; for (let i = 0; i < bytes.length; i++) c = ZIP_CRC_TABLE[(c ^ bytes[i]) & 0xFF] ^ (c >>> 8); return (c ^ 0xFFFFFFFF) >>> 0; } // files: [{ name:string, data:Uint8Array }] → Blob(标准 ZIP,资源管理器/解压软件均可打开) function makeZipBlob(files) { const enc = new TextEncoder(); const chunks = [], central = []; let offset = 0; files.forEach(function (f) { const nameBytes = enc.encode(f.name); const crc = zipCrc32(f.data), sz = f.data.length; const lh = new DataView(new ArrayBuffer(30)); lh.setUint32(0, 0x04034b50, true); // local file header signature lh.setUint16(4, 20, true); // version needed lh.setUint16(6, 0x0800, true); // UTF-8 文件名标志 lh.setUint16(8, 0, true); // store lh.setUint16(10, 0, true); lh.setUint16(12, 0, true); // time/date(留空即可) lh.setUint32(14, crc, true); lh.setUint32(18, sz, true); lh.setUint32(22, sz, true); lh.setUint16(26, nameBytes.length, true); lh.setUint16(28, 0, true); chunks.push(new Uint8Array(lh.buffer), nameBytes, f.data); const ch = new DataView(new ArrayBuffer(46)); ch.setUint32(0, 0x02014b50, true); // central directory signature ch.setUint16(4, 20, true); ch.setUint16(6, 20, true); ch.setUint16(8, 0x0800, true); ch.setUint16(10, 0, true); ch.setUint16(12, 0, true); ch.setUint16(14, 0, true); ch.setUint32(16, crc, true); ch.setUint32(20, sz, true); ch.setUint32(24, sz, true); ch.setUint16(28, nameBytes.length, true); ch.setUint32(42, offset, true); central.push(new Uint8Array(ch.buffer), nameBytes); offset += 30 + nameBytes.length + sz; }); let centralSize = 0; central.forEach(function (c) { centralSize += c.length; }); const eocd = new DataView(new ArrayBuffer(22)); eocd.setUint32(0, 0x06054b50, true); eocd.setUint16(8, files.length, true); eocd.setUint16(10, files.length, true); eocd.setUint32(12, centralSize, true); eocd.setUint32(16, offset, true); return new Blob(chunks.concat(central, [new Uint8Array(eocd.buffer)]), { type: 'application/zip' }); } // 抓单张图的字节:同源/CORS 放行的直接 fetch;防盗链/跨域回退 GM_xmlhttpRequest(复用 blob 缓存) async function fetchImageBytes(url) { try { const res = await fetch(url, { credentials: 'omit' }); if (res.ok) { const buf = await res.arrayBuffer(); if (buf.byteLength > 0) return new Uint8Array(buf); } } catch (e) { } const blobUrl = await gmFetchBlobUrl(url); // 抛错则由调用方跳过该张 const res2 = await fetch(blobUrl); return new Uint8Array(await res2.arrayBuffer()); } // 把整组图集打包成 ZIP 并下载 async function downloadGalleryZipFor(inst) { if (!inst || !inst.galleryList || inst.galleryList.length < 2) { showToast('当前不是图集', 1200); return true; } const list = inst.galleryList.filter(function (im) { return im && im.isConnected !== false; }); if (!list.length) { showToast('图集内容不可用', 1500); return true; } showToast('图集打包中(' + list.length + ' 张)…', 2500); let host = ''; try { host = location.hostname.replace(/^www\./, ''); } catch (e) { } const zipName = sanitizeFileName('图集_' + host + '_' + list.length + '张_' + (document.title || '').slice(0, 30)) + '.zip'; const files = []; let okN = 0; for (let i = 0; i < list.length; i++) { const im = list[i]; let u = ''; try { u = new URL(im.currentSrc || im.src || '', location.href).href; } catch (e) { u = im.src || ''; } if (!u) continue; // blob:/data: 也能被 fetch 直接读取,无需排除 try { const bytes = await fetchImageBytes(u); if (!bytes || !bytes.length) continue; let base = ''; try { base = decodeURIComponent((new URL(u)).pathname.split('/').pop() || ''); } catch (e) { base = ''; } base = sanitizeFileName(base) || ('img_' + (i + 1)); // 防重名:同包内同名加序号 if (files.some(function (x) { return x.name === base; })) { base = base.replace(/(\.[^.]+)?$/, '_' + (i + 1) + '$1'); } files.push({ name: base, data: bytes }); okN++; } catch (e) { /* 单张失败跳过,不阻塞整包 */ } } if (!okN) { showToast('打包失败:一张都没抓到', 2000); return true; } try { const blob = makeZipBlob(files); const objUrl = URL.createObjectURL(blob); downloadViaAnchor(objUrl, zipName); setTimeout(function () { URL.revokeObjectURL(objUrl); }, 30000); showToast('已打包 ' + okN + '/' + list.length + ' 张:' + zipName, 2400); } catch (e) { showToast('打包失败:' + (e && e.message || e), 2000); } return true; } // ---------------- 反馈:预览激活时留一份小尺寸截图(仅本地,供「反馈问题」附带) ---------------- let lastPreviewShotData = null; // dataURL(PNG);跨域直连图会污染 canvas → 抓不到就留空 function capturePreviewShot(inst) { try { const im = inst && inst.imgEl; if (!im || !im.naturalWidth) return; const MAXS = 900; const f = Math.min(1, MAXS / Math.max(im.naturalWidth, im.naturalHeight)); const cv = document.createElement('canvas'); cv.width = Math.max(1, Math.round(im.naturalWidth * f)); cv.height = Math.max(1, Math.round(im.naturalHeight * f)); cv.getContext('2d').drawImage(im, 0, 0, cv.width, cv.height); lastPreviewShotData = cv.toDataURL('image/png'); } catch (e) { lastPreviewShotData = null; } // canvas 被跨域污染等情况:静默放弃截图 } function buildDownloadName(inst, url) { const s = inst && inst.sourceImg; let title = ''; try { title = (document.title || '').trim(); } catch (e) { } if (!title && s) title = (s.alt || '').trim(); title = sanitizeFileName(title) || 'image'; const size = savedImageSize(inst); const dim = (size.w > 0 && size.h > 0) ? ('_' + size.w + 'x' + size.h) : ''; return title + dim + '.' + guessExt(url); } // 降级下载:。同源可直接落盘;跨域时浏览器多半改为「另存为」, // 这是在不引入额外权限的前提下能做到的最好结果。 function downloadViaAnchor(url, name) { try { const a = document.createElement('a'); a.href = url; a.download = name; a.rel = 'noopener'; a.style.display = 'none'; (document.body || document.documentElement).appendChild(a); a.click(); setTimeout(function () { if (a.parentNode) a.parentNode.removeChild(a); }, 0); showToast('已开始下载:' + name, 1800); } catch (e) { showToast('保存失败', 1600); } } // ---------------- 复制 ---------------- // 复制文本:GM_setClipboard 优先 → navigator.clipboard → 临时 textarea function copyTextLegacy(text) { try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.cssText = 'position:fixed;left:-9999px;top:0'; (document.body || document.documentElement).appendChild(ta); ta.select(); const ok = document.execCommand('copy'); if (ta.parentNode) ta.parentNode.removeChild(ta); return !!ok; } catch (e) { return false; } } function copyText(text) { return new Promise(function (resolve) { try { if (typeof GM_setClipboard === 'function') { GM_setClipboard(text, 'text'); resolve(true); return; } } catch (e) { } try { if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then( function () { resolve(true); }, function () { resolve(copyTextLegacy(text)); } ); return; } } catch (e) { } resolve(copyTextLegacy(text)); }); } // 复制图片本体:canvas → blob → ClipboardItem。 // ⚠️ 跨域且未开放 CORS 的图片会污染 canvas,此时浏览器必然拒绝(安全限制,脚本无法绕过)。 function copyImageBody(inst) { return new Promise(function (resolve) { const img = inst && inst.imgEl; if (!img || !img.naturalWidth) { resolve('noimg'); return; } if (!(window.ClipboardItem && navigator.clipboard && navigator.clipboard.write)) { resolve('unsupported'); return; } let canvas; try { canvas = document.createElement('canvas'); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; canvas.getContext('2d').drawImage(img, 0, 0); } catch (e) { resolve('tainted'); return; } try { canvas.toBlob(function (blob) { if (!blob) { resolve('noblob'); return; } navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]).then( function () { resolve('ok'); }, function () { resolve('writefail'); } ); }, 'image/png'); } catch (e) { resolve('tainted'); } // 污染 canvas 调 toBlob 会抛 SecurityError }); } // 图集识别:从当前图往上找祖先,第一个包含 ≥2 张「可见且够大」图片的层即图集容器(上溯上限 GALLERY_MAX_DEPTH 层)。 const GALLERY_MAX = 80; const GALLERY_MIN_PX = 60; // 上溯层数上限 8:覆盖表格化/深嵌套布局(图集容器常在第 6~7 层祖先),同时避免把整页图片算成一个图集。 const GALLERY_MAX_DEPTH = 8; function isGalleryCandidate(el) { if (!el || !el.isConnected) return false; if (el.closest && el.closest('.image-zoom-container')) return false; const r = el.getBoundingClientRect(); if (r.width < GALLERY_MIN_PX || r.height < GALLERY_MIN_PX) return false; const st = getComputedStyle(el); return st.display !== 'none' && st.visibility !== 'hidden' && st.opacity !== '0'; } // 返回按文档顺序排列的图集列表;识别不到就返回只含自身的一项 function collectGalleryImages(img) { if (!img || !img.isConnected) return []; let root = img.parentElement; for (let depth = 0; depth < GALLERY_MAX_DEPTH && root && root !== document.body; depth++) { const imgs = Array.prototype.slice.call(root.querySelectorAll('img')).filter(isGalleryCandidate); if (imgs.length >= 2) { if (imgs.indexOf(img) < 0) imgs.unshift(img); return imgs.slice(0, GALLERY_MAX); } root = root.parentElement; } return [img]; } // 进出全屏时重算预览的缩放基准(zoomFSM 在后面才定义,所以包 try/catch 防 TDZ) document.addEventListener('fullscreenchange', () => { try { if (!zoomFSM || !zoomFSM.hasActiveZoom()) return; zoomFSM.dispatch('FULLSCREEN_LAYOUT', { on: !!document.fullscreenElement }); } catch (e) { } }); // 单次出现位置覆盖:若用户设「屏幕居中」,下一次图片预览临时改用「原图周围」(避免挡住站点自带视频预览), // 仅生效一次、不写回 config。装载点:视频预览关闭时 / 视频预览开关关闭且悬停卡片时。 const PLACE_ONCE_WINDOW = 5000; // 触发后多久内有效(够用户重新移到图上;过期自动作废) let placeOnce = null; // 'around' | null let placeOnceAt = 0; 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; } // 预览过渡动画(入场 / 收起):容器 transform 由 applyZoom 独占,动画只作用于图片与装饰层; // 动画结束一律「先写内联终态 → 再释放动画」,不用 commitStyles。方案:dock / spotlight / fade。 const PT_EASE = 'cubic-bezier(.16,1,.3,1)'; const PT_SP_EASE = 'cubic-bezier(.3,1.25,.3,1)'; // spotlight 用爆感缓动:快速冲进 + 轻微回弹过冲(区别于 dock 的顺滑滑行) // 两套方案的性格参数:dock = 整体位移(慢而长,320~560);spotlight = 光圈收敛(快而收,220~380)。 const PT_DUR = { dock: 420, spotlight: 380 }; // 聚焦专用缓动:先快后稳地收住(与 dock 的顺滑滑行明显区分) // 可调:cubic-bezier(.2,.7,.3,1)=干脆收敛;(.34,1.2,.64,1)=带一点过冲的“聚拢” const PT_AP_EASE = 'cubic-bezier(.2,.7,.3,1)'; // 聚焦时图片的起始缩放(1 = 不做缩放;0.90~0.97 越小“从内向外长”越明显) const PT_AP_SCALE = 0.94; // 聚焦时图片的位移占比(0 = 完全不位移、纯靠光圈;0.15 = 带一点轻微靠拢;建议 ≤0.2) const PT_AP_SHIFT = 0.06; // 聚焦时图片的起始不透明度(0.2~0.5;越低越像“从光里浮出来”) const PT_AP_OPACITY = 0.35; const PT_LAG = 90; // 辅助层(外壳/信息栏)与主体的时序差 // 收起动画结束后的清理缓冲(建议 30~80;过大=残留可见,过小=可能截断最后一帧) const PT_CLEANUP = 40; let ptToken = 0; // 每次进入/收起领新号;旧流程过期即退让 function ptReducedMotion() { try { return matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) { return false; } } function ptMode() { const m = config.previewTransition; return (!m || !PT_DUR[m] || ptReducedMotion()) ? 'fade' : m; } // FLIP 几何:从「原图当前 rect」到「放大图当前 rect」 function ptGeom(inst) { try { const im = inst && inst.imgEl, src = inst && inst.sourceImg; if (!im || !src || !src.isConnected) return null; if ((((Number(inst.rotation) || 0) % 360) + 360) % 360 !== 0 || inst.flipH || inst.flipV) return null; const ir = im.getBoundingClientRect(), sr = src.getBoundingClientRect(); if (!(ir.width > 2 && ir.height > 2 && sr.width > 2 && sr.height > 2)) return null; return { dx: (sr.left + sr.width / 2) - (ir.left + ir.width / 2), dy: (sr.top + sr.height / 2) - (ir.top + ir.height / 2), s: Math.max(0.02, sr.width / ir.width), src: { l: sr.left, t: sr.top, w: sr.width, h: sr.height }, dst: { l: ir.left, t: ir.top, w: ir.width, h: ir.height } }; } catch (e) { return null; } } function ptTf(dx, dy, s) { return 'translate(' + Math.round(dx) + 'px,' + Math.round(dy) + 'px) scale(' + s.toFixed(4) + ')'; } // spotlight 光圈半径:圆心到四角的最远距离作完整半径,确保绽开后完全覆盖图片不露边;起始半径取极小值。 function ptSpotRadius(im, lx, ly) { try { const elW = im.offsetWidth || im.clientWidth || 0; const elH = im.offsetHeight || im.clientHeight || 0; const dx = Math.max(lx, elW - lx); // 圆心到左/右两侧较远者 const dy = Math.max(ly, elH - ly); // 圆心到上/下两侧较远者 const full = Math.ceil(Math.sqrt(dx * dx + dy * dy)) + 4; // 到最远角 + 余量 const tiny = Math.max(2, Math.round(Math.min(elW, elH) * 0.04)); return { tiny: tiny, full: full }; } catch (e) { return { tiny: 2, full: 9999 }; } } // 清掉元素上全部 WAAPI 动画(含 fill:forwards 的旧动画)。快速连续悬停/切图时旧动画 // 未 settle 就会叠加新动画,fill 帧 + 合成层堆积 → 残影;这里统一释放。 function ptClearAnim(el) { if (!el) return; try { el.getAnimations().forEach(function (x) { x.cancel(); }); } catch (e) { } } // 落定(入场):先写内联终态(此刻被动画 fill 压住,视觉无变化)再释放动画 function ptSettleIn(inst, a, clearClip) { try { const im = inst.imgEl, c = inst.container; if (im) { im.style.transform = 'none'; im.style.opacity = '1'; if (clearClip) im.style.clipPath = 'none'; ptClearAnim(im); // ★ 释放 img 上全部动画(不止传入的 a),杜绝旧 fill 帧残留 } if (c) c.querySelectorAll('.hv-hull, .hv-capbar').forEach(function (el) { el.style.transform = 'none'; el.style.opacity = '1'; ptClearAnim(el); }); if (a) a.cancel(); } catch (e) { } } // ★ spotlight「炸开」:在源图中心生成一个径向白光,scale 0→爆散→淡出。 // 独立浮层(不裁剪图片本身)→ 动画结束彻底移除节点,杜绝「光斑残留/全图一圈」。 // lx/ly 为源图中心在容器内的坐标(本函数在容器内绝对定位)。 function ptFlare(c, lx, ly, size, D, entering) { try { const f = document.createElement('div'); f.className = 'hv-flare'; f.setAttribute('aria-hidden', 'true'); Object.assign(f.style, { position: 'absolute', left: (lx - size / 2) + 'px', top: (ly - size / 2) + 'px', width: size + 'px', height: size + 'px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(255,255,255,.96) 0%, rgba(196,220,255,.6) 38%, rgba(150,190,255,0) 68%)', pointerEvents: 'none', zIndex: '2', willChange: 'transform,opacity', opacity: entering ? '0' : '1' }); (c || document.body).appendChild(f); // 进场:白光瞬间爆开到最大(scale 冲过 1)再轻微回缩、同时整体淡出 —— 炸开感 // 出场:白光在源点再闪一下随即收拢消散 const frame = entering ? [{ transform: 'scale(0)', opacity: 0 }, { transform: 'scale(1.25)', opacity: .95, offset: .42 }, { transform: 'scale(1)', opacity: 0 }] : [{ transform: 'scale(1.1)', opacity: .9 }, { transform: 'scale(.85)', opacity: .5, offset: .45 }, { transform: 'scale(0)', opacity: 0 }]; const tot = entering ? Math.max(360, D) : D; const a = f.animate(frame, { duration: tot, easing: 'cubic-bezier(.16,1,.3,1)', fill: 'forwards' }); setTimeout(function () { try { a.cancel(); if (f.parentNode) f.parentNode.removeChild(f); } catch (e) { } }, tot + 90); } catch (e) { } } function ptAux(c, D, entering) { // ★ 只负责信息栏(capbar)。外壳(hull)由各分支内联处理(dock 跟图 FLIP、 // spotlight 独立淡入/淡出),这里不再重复动画 —— 避免两条 fill:forwards 叠加, // 离场时 ptAux 的首关键帧 opacity:1 会把已淡出的 hull 拉回 → 残影。 const bar = c.querySelector('.hv-capbar'); if (bar) { if (entering) bar.animate([{ opacity: 0, transform: 'translateY(-6px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 240, delay: Math.max(60, D - 110), easing: PT_EASE, fill: 'forwards' }); else bar.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 180, easing: PT_EASE, fill: 'forwards' }); } } function ptEnter(inst) { const mode = ptMode(); if (mode === 'fade') return false; const geom = ptGeom(inst); if (!geom) return false; const c = inst.container, im = inst.imgEl; if (!c || !im) return false; const D = PT_DUR[mode], tk = ++ptToken; try { // ★ 先释放上轮遗留动画再入场 —— 防止快速悬停/切图时旧 fill 帧叠加产生残影 ptClearAnim(im); ptClearAnim(c.querySelector('.hv-hull')); ptClearAnim(c.querySelector('.hv-capbar')); im.style.clipPath = 'none'; c.style.opacity = '1'; im.style.opacity = '1'; im.style.transformOrigin = 'center center'; if (mode === 'dock') { const a = im.animate([ { transform: ptTf(geom.dx, geom.dy, geom.s), opacity: .25 }, { transform: 'none', opacity: 1 } ], { duration: D, easing: PT_EASE, fill: 'forwards' }); const hull = c.querySelector('.hv-hull'); if (hull) hull.animate([ // 外壳与图片同一条路径 { transform: ptTf(geom.dx, geom.dy, (geom.src.w + 20) / (geom.dst.w + 20)), opacity: 0 }, { transform: 'none', opacity: 1 } ], { duration: D, easing: PT_EASE, fill: 'forwards' }); const bar = c.querySelector('.hv-capbar'); if (bar) bar.animate([{ opacity: 0, transform: 'translateY(-6px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 240, delay: Math.max(60, D - 110), easing: PT_EASE, fill: 'forwards' }); setTimeout(function () { if (ptToken === tk) ptSettleIn(inst, a, false); }, D + 90); } else { // spotlight:从原图绽开 —— 图片几乎不动(原地), // 用 clip-path 圆形光圈从源点向外绽开 + 轻微缩放;白光爆闪作为增强保留。 // 与 dock 的区别:dock 是「整块滑行」,spotlight 是「原地聚焦」,肉眼可辨。 const cr = c.getBoundingClientRect(); const lx = (geom.src.l + geom.src.w / 2) - cr.left, ly = (geom.src.t + geom.src.h / 2) - cr.top; const flareSize = Math.hypot(c.clientWidth, c.clientHeight) + 90; // 光圈半径按「圆心到矩形最远角」计算(像素),避免百分比半径在圆心偏离时覆盖不全; // 动画结束由 ptSettleIn 写 clipPath:none 清除,不残留。 const r0 = ptSpotRadius(im, lx, ly); const a = im.animate([ { clipPath: 'circle(' + r0.tiny + 'px at ' + Math.round(lx) + 'px ' + Math.round(ly) + 'px)', transform: ptTf(geom.dx * PT_AP_SHIFT, geom.dy * PT_AP_SHIFT, PT_AP_SCALE), opacity: PT_AP_OPACITY }, { clipPath: 'circle(' + r0.full + 'px at ' + Math.round(lx) + 'px ' + Math.round(ly) + 'px)', transform: 'none', opacity: 1 } ], { duration: D, easing: PT_AP_EASE, fill: 'forwards' }); // ★ 外壳不随图滑行(光圈的重点在光,不在移动的框)—— 与 dock 明显区分 const hull = c.querySelector('.hv-hull'); if (hull) hull.animate([{ opacity: 0 }, { opacity: 1 }], { duration: Math.min(220, D), easing: PT_EASE, fill: 'forwards' }); ptAux(c, D, true); ptFlare(c, lx, ly, flareSize, D, true); // ★ 源点白光爆闪 = 炸开感 setTimeout(function () { if (ptToken === tk) ptSettleIn(inst, a, true); }, D + 90); // true = 落位后清除裁剪 } } catch (e) { return false; } return true; } // 收起动画:返回实际时长(0 = 未接管,调用方退回纯淡出) function ptLeave(container) { const mode = ptMode(); const inst = container && container.__zoomInstance; if (mode === 'fade' || !inst) return 0; const geom = ptGeom(inst); if (!geom) return 0; const im = inst.imgEl, D = PT_DUR[mode], tk = ++ptToken; try { // ★ 先清掉上轮入场动画,从 settle 后的内联状态反向收起, // 避免反向动画叠在旧的 forward 动画上 → 残影。 ptClearAnim(im); ptClearAnim(container.querySelector('.hv-hull')); ptClearAnim(container.querySelector('.hv-capbar')); im.style.clipPath = 'none'; // 起点取「当前实际状态」:动画中途被反向接管也不跳变 const cs = getComputedStyle(im); const startTf = (cs.transform && cs.transform !== 'none') ? cs.transform : 'none'; const startOp = isNaN(parseFloat(cs.opacity)) ? 1 : parseFloat(cs.opacity); let a; if (mode === 'dock') { a = im.animate([ { transform: startTf, opacity: startOp }, { transform: ptTf(geom.dx, geom.dy, geom.s), opacity: 0 } ], { duration: D, easing: PT_EASE, fill: 'forwards' }); const hull = container.querySelector('.hv-hull'); if (hull) hull.animate([ { transform: 'none', opacity: 1 }, { transform: ptTf(geom.dx, geom.dy, (geom.src.w + 20) / (geom.dst.w + 20)), opacity: 0 } ], { duration: D, easing: PT_EASE, fill: 'forwards' }); ptAux(container, D, false); } else { // spotlight:光圈收拢(clip-path full→tiny)+ 轻微缩回收回 + 白光收拢 const cr = container.getBoundingClientRect(); const lx = (geom.src.l + geom.src.w / 2) - cr.left, ly = (geom.src.t + geom.src.h / 2) - cr.top; const flareSize = Math.hypot(container.clientWidth, container.clientHeight) + 90; const r0 = ptSpotRadius(im, lx, ly); // ★ 收起终点用「完整 FLIP 几何」(dx/dy/s):图片缩回原图所在位置。 // 光圈(clip-path) + 源点白光 + 缓动(PT_AP_EASE) 全部保留 —— 只把落点 // 从「原地微移 6%/0.94 缩放」改成「回到原图」,与 dock 的收起语义一致。 a = im.animate([ { clipPath: 'circle(' + r0.full + 'px at ' + Math.round(lx) + 'px ' + Math.round(ly) + 'px)', transform: startTf, opacity: startOp }, { clipPath: 'circle(' + r0.tiny + 'px at ' + Math.round(lx) + 'px ' + Math.round(ly) + 'px)', transform: ptTf(geom.dx, geom.dy, geom.s), opacity: 0 } ], { duration: D, easing: PT_AP_EASE, fill: 'forwards' }); const hull = container.querySelector('.hv-hull'); if (hull) hull.animate([{ opacity: 1 }, { opacity: 0 }], { duration: Math.min(220, D), easing: PT_EASE, fill: 'forwards' }); ptAux(container, D, false); ptFlare(container, lx, ly, flareSize, D, false); // ★ 源点白光闪一下再收拢 } setTimeout(function () { if (ptToken !== tk) return; // ⚠ 不能只 cancel 动画(会回弹到内联值 → 闪一下)。 // 正确顺序:写内联终态(被动画 fill 压住、视觉无变化)→ 释放动画 → 立刻隐藏/清理。 // 这样缩放结束的瞬间就回到稳定终态,不会留下“缩小后还停留一小段”的残影。 try { im.style.transform = 'none'; im.style.opacity = '0'; } catch (e) { } ptClearAnim(im); container.querySelectorAll('.hv-hull, .hv-capbar').forEach(function (el) { try { el.style.transform = 'none'; el.style.opacity = '0'; } catch (e) { } ptClearAnim(el); }); // 光效浮层与合成层痕迹一并清掉(will-change 残留会在移除后短暂留影) container.querySelectorAll('.hv-flare').forEach(function (f) { try { f.parentNode.removeChild(f); } catch (e) { } }); container.style.willChange = 'auto'; container.style.opacity = '0'; }, D + PT_CLEANUP); } catch (e) { return 0; } return D; } function fadeOutContainer(container) { if (!container || !container.parentNode) return; container.dataset.izFading = '1'; const zImg = container.querySelector('img'); const ptDur = ptLeave(container); // ★ 收起动画(0 = 未接管 → 退回纯淡出) if (!ptDur) { container.style.opacity = '0'; if (zImg) { zImg.style.transform = 'scale(.6)'; zImg.style.opacity = '0'; } } if (zImg && zImg.__zoomBlobUrl) { URL.revokeObjectURL(zImg.__zoomBlobUrl); zImg.__zoomBlobUrl = null; } setTimeout(() => { if (container.parentNode) container.parentNode.removeChild(container); // ptLeave 已在 D+PT_CLEANUP 复位并隐藏内容,这里再留一点余量即可移除节点 }, ptDur ? (ptDur + PT_CLEANUP + 30) : 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'; } // 信息栏(capbar)挂在容器下方不占高度:自适应尺寸须把它 + 12px 间距一并从「图片可用高度」里预留,保证整体不超视口。 function capbarReserve() { try { if (!config.showImageInfo) return 0; const bar = document.querySelector('.image-zoom-container .hv-capbar'); // 量不到时(建实例/换图早期信息栏尚未构建)按两行图说的兜底高度 56px 预留。 const bh = (bar && bar.offsetHeight) ? bar.offsetHeight : 56; return bh + 12; // 12 = 容器与信息栏的间距 } catch (e) { return 0; } } // 窗口底边可能被 Dock/任务栏遮挡(innerHeight 仍含被遮挡部分):用「窗口底边」与「屏幕可用区底边」之差扣除。 function visualInnerH() { try { if (typeof screen === 'undefined' || typeof screen.availHeight !== 'number') return window.innerHeight; const vpBottom = window.screenY + window.outerHeight; // 窗口底边(屏幕坐标) const availTop = (typeof screen.availTop === 'number') ? screen.availTop : 0; const availBottom = availTop + screen.availHeight; // 可用区底边 const occluded = Math.max(0, vpBottom - availBottom); return Math.max(160, window.innerHeight - occluded); } catch (e) { return window.innerHeight; } } // 档位 → 预览可用空间上限(computeAdaptiveSize/fixed/smallImg 共用):跟随视口以页面高度为最大高度、 // 只保留信息栏纵向空间;小/中/大按档位比例分档(见 STEP_VIEWPORT_SCALE)。 function stepCaps() { const capRes = capbarReserve(); if (sizeStepIndex() === 3) { return { w: Infinity, h: Math.max(160, visualInnerH() - capRes) }; } const svs = stepViewportScale(); return { w: Math.min((window.innerWidth - 60) * svs, config.maxWidth), h: Math.min(Math.max(160, (visualInnerH() - 60 - capRes) * svs), config.maxHeight) }; } function computeAdaptiveSize(img, rect) { const caps = stepCaps(); const availW = caps.w, availH = caps.h; 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 }; } // 显示位置 placement:center=屏幕居中(默认);around=原图周围(在缩略图剩余空间最大的一侧显示,放不下回退居中)。 // 实现:容器保留 translate(-50%,-50%),只把 left/top 锚点换成目标矩形中心像素。 const PL_GAP = 24; // 与缩略图/光标的间距 const PL_EDGE = 8; // 与视口边缘的最小间隙 // 选「剩余空间最大」的一侧(不做能否放下的判断——放不下时是缩小尺寸,而不是回退覆盖) function pickAroundSide(rect) { const vw = window.innerWidth, vh = visualInnerH(); const sp = { right: vw - rect.right, left: rect.left, bottom: vh - rect.bottom, top: rect.top }; const side = Object.keys(sp).sort(function (a, b) { return sp[b] - sp[a]; })[0]; return { side: side, space: sp[side] }; } // 该侧可用于显示预览的尺寸上限:横向侧限宽、纵向侧限高,另一轴受视口限制 function aroundLimits(side, space) { const vw = window.innerWidth, vh = window.innerHeight; const horizontal = (side === 'left' || side === 'right'); const along = Math.max(0, space - PL_GAP - PL_EDGE); const capRes = capbarReserve(); // 信息栏挂在容器下方,要占掉这部分高度 // ★ 纵向上限改用 stepCaps()(跟随档 = 页面高度 − 信息栏,即 738), // 否则跟随档会被这里的 PL_EDGE*2 再压小 16px → 哥哥反馈「跟随视口不够大」。 // caps.h 已含信息栏预留,保证【图 + 图说】整体不超页面。 const caps = stepCaps(); return horizontal ? { w: along, h: Math.max(0, Math.min(caps.h, vh)) } : { w: Math.max(0, vw - PL_EDGE * 2), h: Math.max(0, Math.min(caps.h, along - capRes)) }; } // 锚点:贴在该侧(因为已按空间收缩,clamp 只是极端兜底,不会导致覆盖原图) function computeAroundAnchor(side, rect, boxW, boxH) { const vw = window.innerWidth, vh = visualInnerH(); // 用可视高度,避免窗口底被 Dock/任务栏遮挡时把盒子放到底部 let cx, cy; if (side === 'right') { cx = rect.right + PL_GAP + boxW / 2; cy = rect.top + rect.height / 2; } else if (side === 'left') { cx = rect.left - PL_GAP - boxW / 2; cy = rect.top + rect.height / 2; } else if (side === 'bottom') { cx = rect.left + rect.width / 2; cy = rect.bottom + PL_GAP + boxH / 2; } else { cx = rect.left + rect.width / 2; cy = rect.top - PL_GAP - boxH / 2; } const capRes = capbarReserve(); // 信息栏在容器下方,下边界要为它留出空间 let lo = boxH / 2 + PL_EDGE; let hi = vh - boxH / 2 - PL_EDGE - capRes; // 盒子比视口还高时上下界交叉(lo>hi):改为把中心钉在视口纵向中部,保证中心点永远在页面内、两端溢出可平移。 if (lo > hi) { lo = hi = Math.max(PL_EDGE * 2, (vh - capRes) / 2); } return { cx: Math.max(boxW / 2 + PL_EDGE, Math.min(cx, vw - boxW / 2 - PL_EDGE)), cy: Math.max(lo, Math.min(cy, hi)) }; } function computePlacementAnchor(mode, rect, boxW, boxH) { const GAP = 24; // 与光标/缩略图的间距 const EDGE = 8; // 与视口边缘的最小间隙 const vw = window.innerWidth, vh = visualInnerH(); const capResN = capbarReserve(); // near 模式下方也要给信息栏留位置,否则信息栏会出视口 const clampC = (cx, cy) => ({ cx: Math.max(boxW / 2 + EDGE, Math.min(cx, vw - boxW / 2 - EDGE)), cy: Math.max(boxH / 2 + EDGE, Math.min(cy, vh - boxH / 2 - EDGE - capResN)) }); if (mode === 'near') { const mx = lastMouse.x >= 0 ? lastMouse.x : vw / 2; const my = lastMouse.y >= 0 ? lastMouse.y : vh / 2; const onLeft = mx < vw / 2; // 光标在左半屏 → 预览优先在光标右侧弹出 // ★ 避让缩略图:光标就悬在缩略图上,直接从光标侧弹会盖住原图。 // 候选顺序:光标侧(锚点取「光标与缩略图边缘的更外侧」)→ 对侧 → 都避不开才容忍重叠。 const trySide = (side) => { const cx0 = side === 'right' ? Math.max(mx, rect.right) + GAP + boxW / 2 : Math.min(mx, rect.left) - GAP - boxW / 2; const c = clampC(cx0, my); const bL = c.cx - boxW / 2, bR = c.cx + boxW / 2; const bT = c.cy - boxH / 2, bB = c.cy + boxH / 2; const overlapsThumb = bL < rect.right && bR > rect.left && bT < rect.bottom && bB > rect.top; return overlapsThumb ? null : c; }; const primary = onLeft ? 'right' : 'left'; return trySide(primary) || trySide(primary === 'right' ? 'left' : 'right') || clampC(primary === 'right' ? mx + GAP + boxW / 2 : mx - GAP - boxW / 2, my); } // around 不在这里处理:它要「先按周围可用空间收缩尺寸、再定位」,见 pickAroundSide/computeAroundAnchor return null; } 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); const caps = stepCaps(); boxW = Math.min(Math.round(rect.width * fit), caps.w); boxH = Math.min(Math.round(rect.height * fit), caps.h); } else { const effScale = Math.max(config.scale || 1, config.minScale || 1); boxW = Math.round(rect.width * effScale); boxH = Math.round(rect.height * effScale); // ★ 按档位上限限制(跟随视口档宽度不设限;小视口下四档才有区分,见 stepCaps) const caps = stepCaps(); if (boxW > caps.w || boxH > caps.h) { const fit = Math.min(caps.w / boxW, caps.h / 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) { // 保留首次放大尺寸(自适应/固定倍数结果)反推倍率;zoom 基准 = 源缩略图渲染尺寸。 wheelInitialZoom = Math.max(1, boxW / wheelBaseW); boxW = Math.max(1, Math.round(wheelBaseW * wheelInitialZoom)); boxH = Math.max(1, Math.round(wheelBaseH * wheelInitialZoom)); } // 显示位置锚点:center → 50%/50%(默认);around → 先按周围可用空间收缩预览尺寸、再贴到该侧(绝不覆盖原图)。 let anchorX = '50%', anchorY = '50%'; let placementMode = 'center'; // 单次覆盖:刚关视频预览时的「避让」(见顶部 placeOnce)。读到即消耗,只影响这一次预览。 let effPlacement = config.previewPlacement; if (placeOnce && (Date.now() - placeOnceAt) <= PLACE_ONCE_WINDOW) effPlacement = placeOnce; placeOnce = null; // ★ around 的「选侧 + 原图矩形」要留给 onload 重锚用(见下方「非 center 显示位置重锚」) let aroundSide = null, aroundRect = null; if (effPlacement === 'around') { const pick = pickAroundSide(rect); if (pick) { const lim = aroundLimits(pick.side, pick.space); const fit = Math.min(1, (lim.w || 1) / boxW, (lim.h || 1) / boxH); // 至少保留 25% 尺寸才有显示意义;再小说明四周实在没空间 → 回退居中 if (fit >= 0.25) { if (fit < 1) { boxW = Math.max(1, Math.round(boxW * fit)); boxH = Math.max(1, Math.round(boxH * fit)); wheelBaseW = boxW; // 同步滚轮缩放基准(zoom=1 即这个「贴合尺寸」) wheelBaseH = boxH; // ★ 基准换了,倍率必须一起重算:否则 currentZoom 仍按旧基准(源缩略图宽) // 计算,onload 里 zoomBaseW * currentZoom 会把盒子重新撑回未收缩前的大 // 尺寸 → around 又盖住原图。收缩后 zoom=1 必须 ⇔ 这个「贴合尺寸」。 wheelInitialZoom = 1; } const anchor = computeAroundAnchor(pick.side, rect, boxW, boxH); if (anchor) { anchorX = Math.round(anchor.cx) + 'px'; anchorY = Math.round(anchor.cy) + 'px'; placementMode = 'around'; aroundSide = pick.side; aroundRect = { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom, width: rect.width, height: rect.height }; } } } } // center 模式:让「图片 + 信息栏」整体在视口内居中——锚点上移 capRes/2 抵消信息栏高度。 if (placementMode === 'center') { const capResC = capbarReserve(); if (capResC > 0) anchorY = Math.round((visualInnerH() - capResC) / 2) + 'px'; } 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:${anchorX};top:${anchorY};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;`; // ★ 外壳(hull):图片的背衬装饰层。绝对定位 inset 负值 → 不占容器尺寸、 // 不参与任何几何计算(缩放/旋转/定位全部沿用既有逻辑),只负责视觉包裹。 try { const hull = document.createElement('div'); hull.className = 'hv-hull'; hull.setAttribute('aria-hidden', 'true'); container.appendChild(hull); } catch (e) { } 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 .2s ease; image-rendering:auto; box-shadow:0 6px 18px rgba(0,0,0,.22); display:block;border-radius:10px;opacity:0;`; // 必须优先 currentSrc:带 srcset 时浏览器只加载 currentSrc,src 可能是从未请求、甚至不是图片的兜底地址。 const rawSrc = img.currentSrc || img.src; // ★ 源图自身加载失败时 currentSrc 为空,会退回相对路径的 src; // 相对地址直连易受 referer 策略影响,且 GM_xmlhttpRequest 无法抓取相对地址 // —— 防盗链绕过等场景会因此直接失败。统一解析为绝对地址兜底。 let fallbackSrc = rawSrc; if (rawSrc && !/^(?:[a-z]+:)?\/\//i.test(rawSrc) && !/^(?:data:|blob:)/i.test(rawSrc)) { try { fallbackSrc = new URL(rawSrc, location.href).href; } catch (e) { fallbackSrc = rawSrc; } } // ★ 京东排除已移除(收编遗留 bug):原内联两步已收编进 HD_UPGRADE_RULES(sized-dir / avif), // 但排除行没删,导致京东域名 hover 完全不做高清升级(内置+规则包规则全部失效) let hiResSrc = (fallbackSrc && upgradeImgUrl(fallbackSrc)) || 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, // 创建时代际;异步回调据此判断是否过期 // 信息浮层专用的“展示用 URL”:黑边裁剪会把 imgEl.src 换成 blob:, // 届时域名/格式都取不到,所以这里单独记一份真实来源地址。 fallbackSrc, infoSrc: fallbackSrc, usingHiRes: false, revealed: false, sourceW: rect.width, sourceH: rect.height, currentZoom: initialZoom, // ★ 进入预览时的倍率(注意:它是相对「源缩略图尺寸」的倍数,通常远大于 1)。 // 「重置缩放」要还原到这里,而不是硬写成 1(那样会退化成缩略图大小)。 initialZoom: initialZoom, // ★ 旋转/翻转状态(R / Shift+R 修改;applyZoom 据此换算显示比例) rotation: 0, flipH: false, flipV: false, // ★ 显示位置模式(center/near/around):滚轮缩放/高清换图后据此重新收拢锚点 placementMode: placementMode, // ★ around 重锚所需的选侧与原图矩形快照(near/center 为 null) aroundSide: aroundSide, aroundRect: aroundRect, wheelZoom: !!config.wheelZoom, zoomBaseW: config.wheelZoom ? wheelBaseW : rect.width, zoomBaseH: config.wheelZoom ? wheelBaseH : rect.height, imageRatio: naturalRatio }; container.__zoomInstance = inst; // ★ 图集:记录同容器内的图片列表与当前位置(← / → 翻页 + 预览框内「3/9」指示) try { inst.galleryList = collectGalleryImages(img); inst.galleryIndex = Math.max(0, inst.galleryList.indexOf(img)); } catch (e) { inst.galleryList = [img]; inst.galleryIndex = 0; } zoomedImg.onload = () => { if (inst.generation !== generation || instance !== inst) return; // 过期代际/实例回调直接丢弃 // 「软 404」防护:服务器对不存在路径返回 200 + 空/非图响应时 naturalWidth=0,按加载失败处理; // 同判据也覆盖真实像素 ≤1×1 的占位/跟踪像素。 if (!(zoomedImg.naturalWidth > 0 && zoomedImg.naturalHeight > 0) || (zoomedImg.naturalWidth <= 1 && zoomedImg.naturalHeight <= 1)) { FSM.dispatch('ERROR', inst); return; } if (inst.wheelZoom && zoomedImg.naturalWidth > 0 && zoomedImg.naturalHeight > 0) { const nextRatio = zoomedImg.naturalWidth / zoomedImg.naturalHeight; // ★ w/h 提升到外层作用域:下方「非 center 显示位置重锚」(placement near/around) 也要用。 // 原来只在内层 if(宽高比变化才进入)里 const 声明 → 宽高比未变时下方引用 w 会 // ReferenceError: w is not defined(around/near + wheelZoom + 高清图与原图同比例必现)。 let w = Math.max(1, Math.round(inst.zoomBaseW * inst.currentZoom)); let h = Math.max(1, Math.round(inst.zoomBaseH * inst.currentZoom)); if (nextRatio > 0 && Math.abs(nextRatio - inst.imageRatio) > 0.0001) { inst.imageRatio = nextRatio; inst.zoomBaseH = Math.max(1, inst.zoomBaseW / nextRatio); w = Math.max(1, Math.round(inst.zoomBaseW * inst.currentZoom)); 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'); } // ★ 兜底:滚轮模式下高清图/大比例图加载后,仍须遵守「自适应初始放大不超视口」语义。 // 无黑边竖图不会触发 cropBlackBars 的 kFit,这里统一把初始盒收进可用空间(含 capbar 预留)。 { const capRes = capbarReserve(); const follow = (sizeStepIndex() === 3); // ★ 跟随视口档:宽度基本不设限(仅在极端横图超过视口宽时兜底),纵向用「页面高度 − 信息栏」; // 小/中/大档保留 60px 边距(与 computeAdaptiveSize 的档位框一致)。 const availW = Math.max(160, window.innerWidth - (follow ? 0 : 60)); const availH = Math.max(160, visualInnerH() - (follow ? capRes : (60 + capRes))); const kFit = Math.min(1, availW / w, availH / h); if (kFit < 1) { w = Math.max(1, Math.round(w * kFit)); h = Math.max(1, Math.round(h * kFit)); 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'); // ★ 同步滚轮缩放基准:kFit 改变了显示尺寸,若不更新 zoomBaseW/H, // 首次滚轮会按旧基准重算(zoomBaseW × newZoom)→ 尺寸跳变 // (竖图"首次滚轮先变很大"bug,与 cropBlackBars 滚轮分支同款处理)。 const cz = Number(inst.currentZoom) > 0 ? inst.currentZoom : 1; inst.zoomBaseW = Math.max(1, w / cz); inst.zoomBaseH = Math.max(1, h / cz); } } // 重采样分级:缩小 / ≈1:1 → auto(平滑);放大超原图且 ≤3 倍 → crisp-edges(锐利);>3 倍 → auto(防马赛克)。 try { const dispW = parseFloat(zoomedImg.style.width) || 0; const natW = zoomedImg.naturalWidth || 0; if (dispW > 0 && natW > 0) { zoomedImg.style.imageRendering = (dispW > natW + 1 && dispW / natW <= 3) ? 'crisp-edges' : 'auto'; } } catch (e) { } // ★ 非 center 显示位置:高清换图重算尺寸后收拢锚点(仍按当前倍率的盒子算, // 不干预用户此后的滚轮放大/平移自由) if (inst.placementMode === 'near') { const c = inst.container; const fitAxis = (cur, half, total) => (half * 2 + 16 > total) ? total / 2 : Math.max(half + 8, Math.min(cur, total - half - 8)); c.style.left = fitAxis(parseFloat(c.style.left) || window.innerWidth / 2, w / 2, window.innerWidth) + 'px'; c.style.top = fitAxis(parseFloat(c.style.top) || window.innerHeight / 2, h / 2, window.innerHeight) + 'px'; } else if (inst.placementMode === 'around' && inst.aroundSide && inst.aroundRect) { // around 不能走上面的视口 clamp(会把 createInstance 算好的锚点抹掉 → 退化为居中盖住原图); // 这里按当时的选侧与原图矩形重算锚点。 const c = inst.container; const curW = c.offsetWidth || w; const curH = c.offsetHeight || h; const a = computeAroundAnchor(inst.aroundSide, inst.aroundRect, curW, curH); if (a) { c.style.left = Math.round(a.cx) + 'px'; c.style.top = Math.round(a.cy) + 'px'; } } } // 加载成功的图未必是建实例时的图(如后台探活替换的高清图),比例不同时需按新比例重做 contain 适配, // 否则内联 object-fit:fill 会把图片拉伸变形。 if (!inst.wheelZoom && zoomedImg.naturalWidth > 0 && zoomedImg.naturalHeight > 0) { refitInstanceImage(inst); } // 黑边裁剪对「滚轮缩放模式」同样启用(否则换高清图后带黑边会撑大预览超页); // cropBlackBars 内部已有滚轮保护分支。 cropBlackBars(zoomedImg); updateImageInfo(inst); // 信息栏构建完成后再校正一次尺寸:用实际信息栏高度重算可用高度并等比校正;仅「初始未缩放」时生效,不干扰滚轮。 try { if (inst.wheelZoom && Math.abs(Number(inst.currentZoom) - Number(inst.initialZoom)) < 0.001) { // ★ 必须用 stepCaps() 按档位取框(此时 capbarReserve() 已能拿到实际信息栏高度); // 若对所有档统一用「视口−边距−信息栏」,小/中/大三档会被一起放大到同一上限 → 档位被抹平。 const caps2 = stepCaps(); const availW2 = caps2.w, availH2 = caps2.h; const curW = inst.container.offsetWidth, curH = inst.container.offsetHeight; if (curW > 0 && curH > 0) { const k2 = Math.min(availW2 / curW, availH2 / curH); if (Math.abs(k2 - 1) > 0.01) { const nw = Math.max(1, Math.round(curW * k2)); const nh = Math.max(1, Math.round(curH * k2)); inst.container.style.setProperty('width', nw + 'px', 'important'); inst.container.style.setProperty('height', nh + 'px', 'important'); inst.imgEl.style.setProperty('width', nw + 'px', 'important'); inst.imgEl.style.setProperty('height', nh + 'px', 'important'); const cz = Number(inst.currentZoom) > 0 ? inst.currentZoom : 1; inst.zoomBaseW = Math.max(1, nw / cz); inst.zoomBaseH = Math.max(1, nh / cz); if (inst.placementMode === 'around' && inst.aroundSide && inst.aroundRect) { const a = computeAroundAnchor(inst.aroundSide, inst.aroundRect, nw, nh); if (a) { inst.container.style.left = Math.round(a.cx) + 'px'; inst.container.style.top = Math.round(a.cy) + 'px'; } } else if (inst.placementMode === 'near') { const c = inst.container; const fitAxis = (cur, half, total) => (half * 2 + 16 > total) ? total / 2 : Math.max(half + 8, Math.min(cur, total - half - 8)); c.style.left = fitAxis(parseFloat(c.style.left) || window.innerWidth / 2, nw / 2, window.innerWidth) + 'px'; c.style.top = fitAxis(parseFloat(c.style.top) || window.innerHeight / 2, nh / 2, window.innerHeight) + 'px'; } } } } } catch (e) { } if (!inst.revealed) { inst.revealed = true; FSM.dispatch('LOADED', inst); } }; zoomedImg.onerror = () => { if (inst.generation !== generation || instance !== inst) return; // ★ 防盗链绕过兜底:URL 无尺寸指令(无升级候选)且尚未用脚本身份抓取过时, // 原图直连失败就直接用 GM 身份重新抓取(带 referer 被 403 的典型防盗链场景)。 if (hdCandidates.length === 0 && !zoomedImg.__zoomBlobUrl) { gmFetchBlobUrl(fallbackSrc) .then((blobUrl) => applyHiRes(fallbackSrc, blobUrl)) .catch(() => { FSM.dispatch('ERROR', inst); }); return; } FSM.dispatch('ERROR', inst); }; zoomedImg.src = fallbackSrc; // ★ 智能高清升级器:两段式探测 —— 先探「规则链结果」(够用就停,不浪费流量), // 不够用/不可用再探「尺寸档位变体」;全失败再走防盗链抓取;都失败保持源图。 const hd = (function () { try { return buildHdCandidates(fallbackSrc); } catch (e) { return { primary: [], variants: [] }; } })(); const hdPrimary = (function () { const arr = []; if (hiResSrc) arr.push(hiResSrc); // 只先探「规则链的最终结果」 return arr; })(); const hdExtra = hd.primary.concat(hd.variants) .filter(function (u) { return hdPrimary.indexOf(u) < 0; }) .slice(0, HD_MAX_CANDIDATES); const hdCandidates = hdPrimary.concat(hdExtra); const ac = currentAbort; const applyHiRes = (finalUrl, blobUrl) => { if (ac && ac.signal.aborted) return; if (inst.generation !== generation) return; if (!zoomedImg.isConnected) { if (blobUrl) releaseBlobUrl(blobUrl); return; } if (!blobUrl && zoomedImg.src === finalUrl) return; if (zoomedImg.__zoomBlobUrl) releaseBlobUrl(zoomedImg.__zoomBlobUrl); zoomedImg.__zoomBlobUrl = blobUrl || null; delete zoomedImg.dataset.zoomCropped; inst.usingHiRes = true; inst.infoSrc = finalUrl; zoomedImg.src = blobUrl || finalUrl; updateImageInfo(inst); }; if (hdCandidates.length) { if (HV_DEBUG) { inst.hdDiag = '最终结果' + hdPrimary.length + '·备选' + hdExtra.length; updateImageInfo(inst); } const dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); const availNow = Math.min( Math.min(window.innerWidth - 60, config.maxWidth), Math.min(window.innerHeight - 60, config.maxHeight) ); // 够用标准 = 预览可能的最大显示尺寸 × 设备像素比(不加余量:宁可取小一档,也别为了清晰抓巨图) const needMax = Math.max(600, Math.min(2400, Math.round(availNow * dpr))); const finishWith = (loaded, extra) => { if (ac && ac.signal.aborted) return; const best = pickBestCandidate(loaded, needMax); if (HV_DEBUG) { inst.hdDiag = hdCandidates.length + '候选·探成' + loaded.length + '·选' + (best ? Math.max(best.w, best.h) + 'px' : '无') + '·需' + needMax + (extra || ''); } if (best) { applyHiRes(best.url, null); return; } // 全部直连失败 → 防盗链绕过(以脚本身份抓第一候选) gmFetchBlobUrl(hdCandidates[0]) .then((blobUrl) => applyHiRes(hdCandidates[0], blobUrl)) .catch(() => { if (HV_DEBUG) updateImageInfo(inst); /* 静默回退:保持源图预览 */ }); }; if (hdPrimary.length) { probeCandidates(hdPrimary, 2500).then((loaded1) => { if (ac && ac.signal.aborted) return; const enough = loaded1.filter((c) => Math.max(c.w, c.h) >= needMax); if (enough.length || !hdExtra.length) { finishWith(loaded1, enough.length ? '·够用即停' : ''); return; } // 规则结果不够用/不可用 → 再探备选(中间态 + 尺寸变体) probeCandidates(hdExtra, 2500).then((loaded2) => finishWith(loaded1.concat(loaded2), '·含备选')); }); } else { probeCandidates(hdExtra, 2500).then((loaded) => finishWith(loaded, '·仅备选')); } } 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(); // ★ 入场动画(动画模式由 ptEnter 接管显示观感;返回 false 时退回纯淡入) if (!ptEnter(inst)) { inst.container.style.opacity = '1'; inst.imgEl.style.opacity = '1'; } // 容器已完全显示,此刻刷新信息浮层并亮出(加载阶段先不显) updateImageInfo(inst); // ★ 历史记录:预览真正显示出来才记(图集翻页每张各记一条) recordHistory(inst); // ★ 反馈截图:留一份小尺寸快照(仅本地,供面板「反馈问题」附带) capturePreviewShot(inst); // 图片超出视口时提示当前滚轮操作(1200ms 后自动消失,避免遮挡) if (inst.container.offsetHeight > window.innerHeight && !inst.toastShown) { inst.toastShown = true; showToast(config.wheelZoom ? '滚动滚轮缩放图片' : '图片超出屏幕,滚动滚轮查看其余部分', 800); } state = S.ACTIVE; // ★ 通知其他 frame:本 frame 已有 ACTIVE 预览 arbiterStartRenew(); // ★ P1 竞态修复:准入检查与占位是两次独立读写,两帧可能同时通过。 // 这里在复核窗口后再确认归属,输的一方主动让出,保证屏幕上只有一个预览。 (function verifyArbiter() { const gen = generation, instRef = inst; setTimeout(() => { // 期间已切图 / 已收起 → 不属于本次仲裁范围,不动 if (instance !== instRef || generation !== gen) return; if (!arbiterElectionLost()) return; // 赢得选举 → 正常保留 arbiterCooldownUntil = Date.now() + ARBITER_COOLDOWN; arbiterStopRenew(); // 回退:HOVER_NONE(force) 会走 beginFade,一并释放仲裁 FSM.dispatch('HOVER_NONE', { x: lastMouse.x, y: lastMouse.y, force: true }); }, ARBITER_SETTLE); })(); }, 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(); // 源图直连、高清候选、GM 兜底抓取全部失败:给出明确提示,避免用户「悬停后空白框一闪 / 无反应」却不知为何。 try { showToast('图片加载失败,已跳过'); } catch (e) { } }, beginFade() { clearPending(); generation++; // ★ 离开/切换:立即作废在途任务(ADR-003) if (currentAbort) { currentAbort.abort(); currentAbort = null; } if (instance) { fadeOutContainer(instance.container); instance = null; } state = S.FADING; arbiterStopRenew(); // ★ 预览开始淡出即释放仲裁 setTimeout(() => { if (state === S.FADING) state = S.IDLE; }, FADE_MS); wheelManager.sync(); }, // 最大倍率:按原图分辨率动态放宽,但不生成失控的超大 DOM(拿不到原图尺寸时用保守上限) maxZoomFor(inst) { const naturalW = Number(inst && inst.sourceImg && inst.sourceImg.naturalWidth) || 0; const naturalH = Number(inst && inst.sourceImg && inst.sourceImg.naturalHeight) || 0; 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; return Math.max(20, Math.min(50, resolutionZoom)); }, // 把某个倍率真正落到容器/图片上。★ 滚轮与键盘走同一条路径,避免两套缩放逻辑跑偏。 // force=true 时即使倍率没变也重新应用(旋转/翻转只改朝向,倍率不变)。 applyZoom(inst, wantZoom, force) { if (!inst) return false; const c = inst.container, im = inst.imgEl; if (!c || !im) return false; const sourceW = Number(inst.sourceW) || (inst.sourceImg ? inst.sourceImg.getBoundingClientRect().width : 0); const sourceH = Number(inst.sourceH) || (inst.sourceImg ? inst.sourceImg.getBoundingClientRect().height : 0); if (!(sourceW > 0 && sourceH > 0)) return false; // 始终以 1× 为下限;上限按原图分辨率动态计算。 const current = Number.isFinite(inst.currentZoom) ? inst.currentZoom : 1; const nextZoom = Math.max(1, Math.min(actions.maxZoomFor(inst), Number(wantZoom) || 1)); if (!force && Math.abs(nextZoom - current) < 0.0001) return false; inst.currentZoom = nextZoom; // 宽高始终由同一个缩放倍率计算,绝不分别缩放,保证横竖图比例恒定。 // 缩放基准固定为实例当前的 zoom=1 尺寸。 // 裁剪、高清图替换等异步过程不会再把这个基准重置成容器尺寸。 const baseW = Number(inst.zoomBaseW) > 0 ? inst.zoomBaseW : sourceW; const ratio = Number(inst.imageRatio) > 0 ? inst.imageRatio : (sourceW / Math.max(1, sourceH)); const baseH = Number(inst.zoomBaseH) > 0 ? inst.zoomBaseH : (baseW / ratio); // ★ 旋转/翻转:旋转 90/270 时「显示比例」要交换,旋转施加在容器上 // (图片只负责填满容器,容器整体旋转 → 比例天然正确,也不干扰图片自身的平移逻辑)。 const rot = ((Number(inst.rotation) || 0) % 360 + 360) % 360; const swapped = (rot % 180) !== 0; // 容器盒子:旋转后要占的位(90/270 时与图片自身宽高互换) const visW = swapped ? baseH : baseW; const visH = swapped ? baseW : baseH; const w = Math.max(1, Math.round(visW * nextZoom)); const h = Math.max(1, Math.round(visH * nextZoom)); // 图片自身尺寸:★ 始终按自身比例,绝不跟着容器交换 —— // 否则图片会被拉伸变形(旋转是靠容器转,图片只要居中即可)。 const iw = Math.max(1, Math.round(baseW * nextZoom)); const ih = Math.max(1, Math.round(baseH * nextZoom)); const tf = ['translate(-50%, -50%)']; if (rot) tf.push('rotate(' + rot + 'deg)'); if (inst.flipH) tf.push('scaleX(-1)'); if (inst.flipV) tf.push('scaleY(-1)'); 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 = tf.join(' '); // 旋转/翻转时隐藏信息栏(外壳保留):文字跟着倒转或镜像会很难读。 // 同时改 class 与内联 opacity —— 内联样式才能稳定压过后面的 setOpacity 同步。 try { const hideCap = !!(rot || inst.flipH || inst.flipV); c.classList.toggle('hv-nocap', hideCap); const bar = c.querySelector('.hv-capbar'); if (bar) bar.style.opacity = hideCap ? '0' : '1'; } catch (e) { } // 不在这里 clamp 锚点:滚轮放大后允许超出视口 + 平移查看;收拢只发生在 createInstance 与高清换图重算尺寸后。 inst.panY = 0; im.style.setProperty('width', iw + 'px', 'important'); im.style.setProperty('height', ih + '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'); // 重采样分级:缩小 / ≈1:1 → auto(平滑);放大超原图时 ≤3 倍用 crisp-edges(锐利)、>3 倍用 auto(防马赛克)。 try { const nw2 = im.naturalWidth || 0, nh2 = im.naturalHeight || 0; if (nw2 > 0 && nh2 > 0) { const up = (iw > nw2 + 1) || (ih > nh2 + 1); const k = Math.max(iw / nw2, ih / nh2); im.style.imageRendering = (up && k <= 3) ? 'crisp-edges' : 'auto'; } } catch (e) { } if (swapped) { // 旋转 90/270:容器盒子与图片宽高互换,图片必须居中才不会被转偏 im.style.setProperty('left', '50%', 'important'); im.style.setProperty('top', '50%', 'important'); im.style.setProperty('transform', 'translate(-50%, -50%)', 'important'); } else { // 未旋转:保持既有定位(平移逻辑依赖 left/top 为 0) im.style.setProperty('left', '0px', 'important'); im.style.setProperty('top', '0px', 'important'); im.style.setProperty('transform', 'none', 'important'); } // 外壳 hull 必须跟「图片」定盒而非容器盒(旋转 90/270 时容器盒与图片宽高互换,按容器定盒会导致框不跟转)。 try { const hull = c.querySelector('.hv-hull'); if (hull) { const hw = iw + 20, hh = ih + 20; hull.style.setProperty('width', hw + 'px', 'important'); hull.style.setProperty('height', hh + 'px', 'important'); hull.style.setProperty('left', Math.round((w - hw) / 2) + 'px', 'important'); hull.style.setProperty('top', Math.round((h - hh) / 2) + 'px', 'important'); hull.style.setProperty('right', 'auto', 'important'); hull.style.setProperty('bottom', 'auto', 'important'); } } catch (e) { } return true; }, // 乘法步进缩放(键盘 +/- 与滚轮共用同一条路径) zoomBy(factor) { if (!instance) return false; const current = Number.isFinite(instance.currentZoom) ? instance.currentZoom : 1; return actions.applyZoom(instance, current * factor); }, // 回到「进入预览时的尺寸」(撤销所有缩放):不能写成 applyZoom(instance, 1)——currentZoom 是相对缩略图尺寸的倍数。 resetZoom() { if (!instance) return false; const base = Number.isFinite(instance.initialZoom) && instance.initialZoom > 0 ? instance.initialZoom : (Number.isFinite(instance.currentZoom) ? instance.currentZoom : 1); return actions.applyZoom(instance, base); }, // ★ 保存图片:优先存「高清原图」地址。 // inst.infoSrc 始终是真实来源地址(高清升级成功时已换成升级后的), // 而 imgEl.src 在黑边裁剪后可能是 blob:,不能拿来下载。 saveImage() { if (!instance) return false; const inst = instance; const url = String(inst.infoSrc || inst.fallbackSrc || '').trim(); if (!url) { showToast('没有可保存的图片地址', 1600); return true; } const name = buildDownloadName(inst, url); // 1) 首选 GM_download:能正确处理跨域 / 防盗链,也不会把页面导航走 try { if (typeof GM_download === 'function') { GM_download({ url: url, name: name, saveAs: false, onload: function () { showToast('已保存:' + name, 1800); }, onerror: function () { downloadViaAnchor(url, name); } }); return true; } } catch (e) { } // 2) 降级: downloadViaAnchor(url, name); return true; }, // 图集打包下载(z):把当前图集整组抓下来打成一个 ZIP async downloadGalleryZip() { return await downloadGalleryZipFor(instance); }, // 复制图片地址(c) copyLink() { if (!instance) return false; const url = String(instance.infoSrc || instance.fallbackSrc || '').trim(); if (!url) { showToast('没有可复制的地址', 1600); return true; } copyText(url).then(function (ok) { showToast(ok ? '已复制图片地址' : '复制失败(浏览器限制)', 1600); }); return true; }, // 复制图片本体(Shift+C) copyImage() { if (!instance) return false; copyImageBody(instance).then(function (r) { if (r === 'ok') showToast('已复制图片', 1500); else if (r === 'unsupported') showToast('此浏览器不支持复制图片', 1800); else if (r === 'tainted') showToast('该图片受跨域限制,无法复制', 2000); else showToast('复制图片失败', 1600); }); return true; }, // 顺时针旋转 90°(r) rotateBy() { if (!instance) return false; instance.rotation = (((Number(instance.rotation) || 0) + 90) % 360); actions.applyZoom(instance, instance.currentZoom, true); showToast('已旋转 ' + instance.rotation + '°', 900); return true; }, // 水平翻转(Shift+R) flipHorizontal() { if (!instance) return false; instance.flipH = !instance.flipH; actions.applyZoom(instance, instance.currentZoom, true); showToast(instance.flipH ? '已水平翻转' : '已还原水平方向', 900); return true; }, // 图集翻页(← / →)。复用 beginFade + startPending,绕过"光标下必须是这张图"的检查。 switchGallery(dir) { const inst = instance; if (!inst) return false; const list = inst.galleryList || []; if (list.length < 2) { showToast('当前不是图集', 1000); return true; } let i = list.indexOf(inst.sourceImg); if (i < 0) i = inst.galleryIndex || 0; const next = (i + dir + list.length) % list.length; const target = list[next]; if (!target || !target.isConnected || target === inst.sourceImg) { showToast('第 ' + (next + 1) + ' 张不可用', 1000); return true; } actions.beginFade(); // ★ 记住翻页时的光标位置:本次预览显示的是"不在光标下"的另一张图, // 需要让心跳的几何校验放行,否则新图一显示就被判为无效而取消。 galleryHoldAt = { x: lastMouse.x, y: lastMouse.y }; actions.startPending(target); showToast((next + 1) + '/' + list.length, 800); return true; }, // 全屏(f)。对 documentElement 全屏:预览本来就是覆盖全页居中的,无需改动布局, // 只需在进出时按屏幕重算一次缩放基准。 toggleFullscreen() { try { const isFs = !!(document.fullscreenElement || document.webkitFullscreenElement); if (isFs) { const exit = document.exitFullscreen || document.webkitExitFullscreen; if (exit) exit.call(document); } else { const el = document.documentElement; const req = el.requestFullscreen || el.webkitRequestFullscreen; if (!req) { showToast('此环境不支持全屏', 1500); return true; } const p = req.call(el); if (p && p.catch) p.catch(function () { showToast('全屏被拒绝', 1500); }); } } catch (e) { showToast('全屏失败', 1500); } return true; }, // 进出全屏时重算缩放基准:全屏下让图片按整个屏幕适配,退出时还原原基准与倍率 applyFullscreenLayout(on) { const inst = instance; if (!inst) return; if (on) { if (inst.fsSaved) return; inst.fsSaved = { baseW: inst.zoomBaseW, baseH: inst.zoomBaseH, zoom: inst.currentZoom, ratio: inst.imageRatio }; const zImg = inst.imgEl; const natW = (zImg && zImg.naturalWidth) || inst.zoomBaseW || 1; const natH = (zImg && zImg.naturalHeight) || inst.zoomBaseH || 1; const ratio = (natW > 0 && natH > 0) ? (natW / natH) : (inst.imageRatio || 1.5); const vw = Math.max(100, window.innerWidth - 40); const vh = Math.max(100, window.innerHeight - 40); let fw = vw, fh = vw / ratio; if (fh > vh) { fh = vh; fw = fh * ratio; } inst.zoomBaseW = Math.max(1, Math.round(fw)); inst.zoomBaseH = Math.max(1, Math.round(fh)); inst.imageRatio = ratio; inst.currentZoom = 1; inst.fullscreen = true; } else { if (!inst.fsSaved) return; inst.zoomBaseW = inst.fsSaved.baseW; inst.zoomBaseH = inst.fsSaved.baseH; inst.imageRatio = inst.fsSaved.ratio; inst.currentZoom = inst.fsSaved.zoom; inst.fsSaved = null; inst.fullscreen = false; } actions.applyZoom(inst, inst.currentZoom, true); }, zoomByWheel(e) { // 缩放使用乘法倍率,避免不同基础倍率下出现“滚轮一下反而缩小一点”的视觉抖动。 // 向上放大约 8%,向下缩小约 7.4%。 actions.zoomBy(e.deltaY < 0 ? 1.08 : (1 / 1.08)); }, 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 (galleryHoldAt || 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 (!galleryHoldAt && !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) { // ★ 图集翻页保持:翻页时目标图不在光标下,canTriggerNow 必为 false。 // 必须与心跳分支(3673 处 galleryHoldAt 放行)保持一致,此处也认可 galleryHoldAt, // 否则定时器一到就落入 cancel(),导致「按 ←/→ 预览直接消失」。 if (galleryHoldAt || 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; } // ★ 键盘动作入口(键位系统调用)。缩放与滚轮共用 applyZoom,行为完全一致。 case 'ZOOM_IN': case 'ZOOM_OUT': case 'ZOOM_RESET': { if (state === S.ACTIVE && instance && isEnabled) { if (event === 'ZOOM_IN') actions.zoomBy(1.08); else if (event === 'ZOOM_OUT') actions.zoomBy(1 / 1.08); else actions.resetZoom(); return true; } return false; } // 保存当前预览的图片(S 键) case 'SAVE_IMAGE': { if (state === S.ACTIVE && instance && isEnabled) { actions.saveImage(); return true; } return false; } // 复制地址 / 复制图片 / 旋转 / 翻转 case 'COPY_LINK': { if (state === S.ACTIVE && instance && isEnabled) { actions.copyLink(); return true; } return false; } case 'COPY_IMAGE': { if (state === S.ACTIVE && instance && isEnabled) { actions.copyImage(); return true; } return false; } case 'ROTATE': { if (state === S.ACTIVE && instance && isEnabled) { actions.rotateBy(); return true; } return false; } case 'FLIP': { if (state === S.ACTIVE && instance && isEnabled) { actions.flipHorizontal(); return true; } return false; } // 图集翻页 / 全屏 case 'GALLERY_PREV': { if ((state === S.ACTIVE || state === S.SHOWING) && instance && isEnabled) { actions.switchGallery(-1); return true; } return false; } case 'GALLERY_NEXT': { if ((state === S.ACTIVE || state === S.SHOWING) && instance && isEnabled) { actions.switchGallery(1); return true; } return false; } case 'GALLERY_ZIP': { if ((state === S.ACTIVE || state === S.SHOWING) && instance && isEnabled) { actions.downloadGalleryZip(); return true; } return false; } case 'TOGGLE_FULLSCREEN': { if ((state === S.ACTIVE || state === S.SHOWING) && instance && isEnabled) { actions.toggleFullscreen(); return true; } return false; } case 'FULLSCREEN_LAYOUT': { if (instance) { actions.applyFullscreenLayout(!!(payload && payload.on)); return true; } return false; } // 主动关闭预览(Esc / 自定义键) // 全屏状态下不拦截 Esc —— 交给浏览器先退出全屏,再按一次才关闭预览 case 'CLOSE': { // 全屏下 Esc 只退出全屏、不关预览。 // 某些环境(无窗口装饰/自动化)不会自动响应 Esc 退全屏,需显式退出。 if (document.fullscreenElement || document.webkitFullscreenElement) { try { const exit = document.exitFullscreen || document.webkitExitFullscreen; if (exit) exit.call(document); } catch (e) { } return true; } if (state === S.PENDING) { actions.cancel(); return true; } if (state === S.SHOWING || state === S.ACTIVE) { actions.beginFade(); 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) { // 必须挂 window(不能挂 document):部分站点在 window 级 capture 就 stopPropagation,挂 document 收不到滚轮。 window.addEventListener('wheel', handler, { capture: true, passive: false }); attached = true; } else if (!need && attached) { window.removeEventListener('wheel', handler, { capture: true }); attached = false; } } document.addEventListener('fullscreenchange', sync); document.addEventListener('webkitfullscreenchange', sync); return { sync }; })(); // 键位系统:默认键表见 KEYMAP_DEFAULTS,可在配置里覆盖;新增动作 = ACTIONS 注册实现 + KEYMAP_DEFAULTS 给默认键。 // 接管原则:① 只在预览显示时接管;② 输入框/可编辑区不接管;③ 带 Ctrl/Cmd/Alt 的组合键不接管; // ④ 只有动作确实消费了该键才 preventDefault。 const keymapModule = (function () { // 动作注册表:返回 true 表示已消费该按键 const ACTIONS = { close: function () { // ★ 视频预览优先关闭:开着视频预览时 Esc 先收视频,再收图片预览 if (videoPreviewModule.hide()) return true; return zoomFSM.dispatch('CLOSE'); }, zoomIn: function () { return zoomFSM.dispatch('ZOOM_IN'); }, zoomOut: function () { return zoomFSM.dispatch('ZOOM_OUT'); }, resetZoom: function () { return zoomFSM.dispatch('ZOOM_RESET'); }, saveImage: function () { return zoomFSM.dispatch('SAVE_IMAGE'); }, copyLink: function () { return zoomFSM.dispatch('COPY_LINK'); }, copyImage: function () { return zoomFSM.dispatch('COPY_IMAGE'); }, rotate: function () { return zoomFSM.dispatch('ROTATE'); }, flip: function () { return zoomFSM.dispatch('FLIP'); }, prevImage: function () { return zoomFSM.dispatch('GALLERY_PREV'); }, nextImage: function () { return zoomFSM.dispatch('GALLERY_NEXT'); }, galleryZip: function () { return zoomFSM.dispatch('GALLERY_ZIP'); }, fullscreen: function () { return zoomFSM.dispatch('TOGGLE_FULLSCREEN'); } }; // 归一化:**字母保留大小写** —— 这样 Shift 组合(C 复制图片、R 翻转) // 才能与小写(c 复制地址、r 旋转)区分开;其余键名(+ - _ Escape ArrowLeft…)原样。 function normalizeKey(k) { return (typeof k === 'string') ? k : ''; } function isTypingTarget(el) { if (!el) return false; const tag = (el.tagName || '').toLowerCase(); return tag === 'input' || tag === 'textarea' || tag === 'select' || el.isContentEditable === true; } // 按键 → 动作名 function resolveAction(key) { if (!key) return null; const table = (config && config.keymap) || KEYMAP_DEFAULTS; const actions = Object.keys(ACTIONS); for (let i = 0; i < actions.length; i++) { const name = actions[i]; const list = (Array.isArray(table[name]) && table[name].length) ? table[name] : (KEYMAP_DEFAULTS[name] || []); for (let j = 0; j < list.length; j++) { if (normalizeKey(list[j]) === key) return name; } } return null; } function handler(e) { if (!isEnabled) return; if (e.defaultPrevented) return; if (e.ctrlKey || e.metaKey || e.altKey) return; if (isTypingTarget(e.target) || isTypingTarget(document.activeElement)) return; const imageActive = zoomFSM.hasActiveZoom(); const videoActive = videoPreviewModule.isActive(); if (!imageActive && !videoActive) return; // 预览(图片/视频)没显示就不掺和 const action = resolveAction(normalizeKey(e.key)); if (!imageActive && videoActive) { // ★ 视频预览独占键盘:Esc 关闭;←/→ 控制播放进度(±5s)。 // 方向键按原生语义直取(不参与图片图集的 prev/next 键位映射), // iframe(YouTube/B站 embed)无法控制进度时放行、不拦按键。 if (e.key === 'ArrowLeft') { if (videoPreviewModule.seek(-5)) { e.preventDefault(); e.stopPropagation(); } return; } if (e.key === 'ArrowRight') { if (videoPreviewModule.seek(5)) { e.preventDefault(); e.stopPropagation(); } return; } if (action === 'close') { const closeFn = ACTIONS[action]; if (closeFn && closeFn() === true) { e.preventDefault(); e.stopPropagation(); } } return; } if (!action) return; const fn = ACTIONS[action]; if (fn && fn() === true) { e.preventDefault(); e.stopPropagation(); } } let attached = false; function init() { if (attached) return; window.addEventListener('keydown', handler, { capture: true }); attached = true; } return { init: init, resolveAction: resolveAction }; })(); // 视频悬停预览:① 悬停页面内