// ==UserScript== // @name 悬景 · HoverVista|鼠标悬停图片自动放大预览 // @namespace https://github.com/YDGG123 // @version 5.9.1 // @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 cdn.jsdelivr.net // @connect fastly.jsdelivr.net // @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.1'; })(); // 调试模式(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); // 全局偏好(跨网站共享):界面语言、历史视图这类「不随站点变化」的选择。 // 与面板里按域名保存的配置分开存,改一次到处生效。 const GLOBAL_PREFS_KEY = 'image_zoom_global_prefs'; let globalPrefs = (function () { try { const raw = storageGet(GLOBAL_PREFS_KEY, null); if (raw && typeof raw === 'object' && !Array.isArray(raw)) return Object.assign({}, raw); } catch (e) { } return {}; })(); function saveGlobalPrefs() { try { storageSet(GLOBAL_PREFS_KEY, Object.assign({}, globalPrefs)); } catch (e) { } } // 历史记录(最近悬停看过的图片;只存本机 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])); } // ===== 界面语言 ===== // auto 跟随浏览器语言;只替换「显示文案」,不参与任何功能逻辑。 function getLang() { if (globalPrefs.lang === 'zh') return 'zh'; if (globalPrefs.lang === 'en') return 'en'; try { return /^zh\b/i.test(navigator.language || '') ? 'zh' : 'en'; } catch (e) { return 'zh'; } } function isEnUI() { return getLang() === 'en'; } // 文案字典:键是「渲染后的完整文本」,值是英文。未收录的文案保持中文原样(可随时增补)。 const I18N_EN = { // —— 品牌与框架 —— '悬景': '', // 品牌区「悬景 + HoverVista」在英文下只保留 HoverVista '改动已自动保存': 'Saved automatically', '关闭 (ESC)': 'Close (ESC)', '关闭(Esc)': 'Close (Esc)', '关闭': 'Close', '完成': 'Done', '取消': 'Cancel', '备注': 'Note', '选填': 'Optional', '打开配置面板': 'Open settings panel', '点击:切换图片放大': 'Click: toggle image zoom', // —— 导航 —— '概览': 'Overview', '总览与诊断': 'Overview', '外观': 'Appearance', '显示与样式': 'Display', '行为': 'Behavior', '触发与交互': 'Triggering', '固定模式参数': 'Fixed-mode params', '键位': 'Keys', '规则与数据': 'Rules & data', '图片规则': 'Image rules', '站点适配': 'Site fit', '历史记录': 'History', // —— 总览区 —— '当前网站': 'This site', '当前网站状态': 'Site status', '仅主页': 'Homepage only', '启用图片放大': 'Enable image zoom', '关闭后本网站的悬停放大与视频预览全部停用(其它网站不受影响)。等效于点击页面上的控制球。': 'Turns off hover zoom and video preview on this site (other sites unaffected). Same as clicking the floating ball.', '当前主页已启用图片放大': 'Image zoom is enabled on the homepage', '当前主页已禁用图片放大': 'Image zoom is disabled on the homepage', '禁用主页图片放大功能': 'Disable zoom on the homepage', '启用主页图片放大功能': 'Enable zoom on the homepage', '站首页通常图片密集、容易误触发;开启后只关首页,内容子页面照常生效。': 'Homepages are image-dense and easy to trigger by accident. This only disables the homepage; inner pages keep working.', '规则与数据概况': 'Rules & data overview', '我的规则': 'My rules', '云端规则包': 'Cloud rule pack', '云端规则包与本站命中情况见「图片规则」': 'See “Image rules” for the cloud pack and this site’s matches', '🔒 签名校验状态与更新入口在「图片规则」': '🔒 Signature status and the update entry live under “Image rules”', '管理': 'Manage', '更新': 'Update', '界面语言': 'Interface language', '只影响显示文案': 'Display text only', '跟随浏览器': 'Auto', '属全局设置,所有网站共用。': 'A global setting shared by all sites.', '遇到问题?': 'Troubleshooting', '点一下直达对应设置': 'One click to the right setting', '图悬停没反应': 'No response on hover', '页面遮罩挡住了鼠标 →': 'An overlay blocks the cursor →', '大图太小 / 太大': 'Preview too small / too big', '换放大模式或调最大宽高 →': 'Change the zoom mode or max width/height →', '图片不够清晰': 'Image not sharp enough', '本站有没有更大的图?→ 在': 'Is there a larger version? → open', '里「点图选图」试试': 'and try “Pick from page”', // —— 显示与样式 —— '决定大图「多大、出现在哪、长什么样」。': 'Controls how big the preview is, where it appears and how it looks.', '放大模式': 'Zoom mode', '方块宽度 = 大图宽度': 'Block width = preview width', '推荐': 'Recommended', '智能自适应': 'Adaptive', '尺寸按每张图自动算,尽量用满屏幕': 'Size is computed per image to fill the screen', '每张图不一样': 'Varies per image', '固定倍数': 'Fixed scale', '始终按你设的倍率,每次大小都一样': 'Always uses your fixed scale — same size every time', '每张图都一样': 'Same for every image', '大图能有多大': 'How big it can get', '方块大小就是大图大小': 'Block size = preview size', '高级:分别设置宽高': 'Advanced: set width and height separately', '大图出现在哪': 'Where it appears', '看动画对比': 'Compare with animations', '屏幕居中': 'Screen center', '位置稳定,每次都一样': 'Stable position, always the same', '原图周围': 'Around the image', '不挡住你正在看的那张图': 'Won’t cover the image you are viewing', '出现方式': 'Entrance animation', '从原图弹出': 'Unfold from image', '整块从原图位置长出来,滑到预览位置': 'The whole block grows out of the image and slides into place', '从原图绽开': 'Bloom from image', '在原图位置用光圈向外扩散': 'A ring expands outward from the image position', '直接淡入': 'Fade in', '最快,没有任何位移': 'Fastest, no movement', '系统开启「减少动态效果」时会自动降级为直接淡入。': 'Falls back to fade-in when the system requests reduced motion.', '显示图片信息栏': 'Show image info bar', '在大图下方显示尺寸、格式、来源和图说。': 'Show size, format, source and caption below the preview.', '小': 'S', '中': 'M', '大': 'L', '跟随视口': 'Fit viewport', // —— 触发与交互 —— '什么时候弹出、怎么操作它,以及和页面点击放大的相处方式。': 'When the preview appears, how to control it, and how it coexists with click-to-zoom.', '触发时机': 'Trigger', '停留多久才弹出': 'Hover delay', '立刻弹出': 'Instant', '太小的图不放大': 'Skip tiny images', '多大的图算小图': 'Small-image threshold', '小图预览宽度': 'Small-image width', '小图预览高度': 'Small-image height', '最小不小于': 'Never smaller than', '匹配选项(一般不用改)': 'Flags (usually unchanged)', '让页面上的图片都能悬停放大': 'Zoom any image on hover', '滚轮放大缩放': 'Zoom with the wheel', '滚轮放大缩小': 'Zoom with the wheel', '只在预览显示时生效。': 'Only while the preview is shown.', '滚轮平移距离': 'Wheel scroll distance', '滚轮与视频': 'Wheel & video', '避免与点击放大功能冲突': 'Avoid sites that zoom on click', '避开「点一下才放大」的网站': 'Avoid sites that zoom on click', '窗口失焦时收起放大图': 'Hide the preview when the window loses focus', '切到别的应用时自动收起': 'Auto-hide when you switch apps', '关掉后,切换应用时预览会保留。': 'When off, the preview stays when you switch apps.', '悬停视频时预览播放': 'Preview videos on hover', '悬停视频卡片时静音浮出播放,按 Esc 关闭。': 'Hover a video card to play it muted; press Esc to close.', '图集邻图预加载': 'Preload gallery neighbours', '翻页前先预热前后张,翻起来更顺;省流量模式与 2G 网络会自动跳过。': 'Warms up the next and previous images before you page, so flipping feels instant. Skipped on data-saver mode and 2G networks.', 'B站全屏音量修正': 'Bilibili fullscreen volume fix', 'B站全屏时如果滚轮调不了音量,开启这项即可。': 'Enable this if the wheel cannot change volume in Bilibili fullscreen.', // —— 固定模式参数 / 数值参数 —— '这些设置只在「固定倍数」模式下生效': 'These settings only apply in fixed-scale mode', '模式,下面 5 项不生效。': 'mode, so the 5 settings below are inactive.', '放大倍数': 'Zoom factor', '大图最大宽度': 'Max width', '大图最大高度': 'Max height', // —— 键位 —— '动作快捷键': 'Action shortcuts', '键位对所有网站通用': 'Keys apply to all sites', '点右侧键帽再按新键即可改绑;按到已占用的键时,原动作自动让出': 'Click a key cap and press a new key. If the key is taken, the previous action yields automatically.', '↩ 恢复默认键位': '↩ Reset keys', '关闭预览': 'Close preview', '放大': 'Zoom in', '缩小': 'Zoom out', '重置缩放': 'Reset zoom', '保存高清图': 'Save full-res', '复制图片地址': 'Copy image URL', '复制图片': 'Copy image', '旋转 90°': 'Rotate 90°', '水平翻转': 'Flip horizontally', '上一张': 'Previous', '下一张': 'Next', '全屏': 'Fullscreen', '打包下载图集': 'Download gallery as ZIP', '收起当前放大图': 'Collapse the current preview', '按步进放大约 8%': 'Zooms in about 8% per press', '按步进缩小约 7.4%': 'Zooms out about 7.4% per press', '回到进入预览时的大小': 'Back to the size when the preview opened', '优先保存高清原图': 'Prefer saving the full-resolution original', '复制真实来源地址': 'Copy the real source URL', '复制图片本体(跨域可能失败)': 'Copy image data (may fail cross-origin)', '顺时针;转四次回原状': 'Clockwise; four presses restore the original', '再按一次还原': 'Press again to restore', '图集内往前翻': 'Previous in gallery', '图集内往后翻': 'Next in gallery', '再按一次退出': 'Press again to exit', '图集模式下按 Z,把整组图打成 ZIP 下载': 'In gallery mode press Z to download the whole set as a ZIP', // —— 图片规则 —— '从上到下依次生效:① 我的规则 → ② 云端规则包 → ③ 内置兜底。': 'Applied top-down: ① My rules → ② Cloud pack → ③ Built-in.', '① 我的规则(优先)→ ② 云端规则包 → ③ 内置兜底': '① My rules (priority) → ② Cloud pack → ③ Built-in', '优先': 'Priority', '这里加的规则会盖过云端和内置规则。': 'Rules added here override the cloud pack and built-ins.', '生效范围': 'Scope', '只在当前网站': 'This site only', '所有网站': 'All sites', '还没有。用上面的「帮我找大图」就能加一条,不用懂任何技术。': 'Nothing yet. Use “Find a larger image” above to add one — no technical knowledge needed.', '高级:手写匹配规则 / 导出': 'Advanced: hand-written rules / export', '规则名称': 'Rule name', '处理时机': 'Phase', '清理图片地址': 'Clean up image URL', '换图规则': 'Image-swap rules', '匹配规则': 'Match pattern', '替换成': 'Replace with', '保存规则': 'Save rule', '+ 为当前网站添加规则': '+ Add a rule for this site', '🔍 分析这个地址': '🔍 Analyze this URL', '🔍 帮我找大图': '🔍 Find a larger image', '🖱 点图选图': '🖱 Pick from page', '🖱️ 拾取选择器': '🖱️ Pick selector', '卡片选择器': 'Card selector', '图片容器选择器(背景图元素的 CSS 选择器)': 'Image container selector (CSS selector of the background element)', '域名(逗号分隔,留空为当前网站)': 'Domains (comma-separated; empty = current site)', '图片看不清?点「点图选图」后,直接在网页上点那张图就行。': 'Image blurry? Click “Pick from page”, then click the image on the page.', '💡 不会写选择器?点「🖱️ 拾取选择器」后直接在页面上点一下图片即可自动填写。': '💡 No selector? Click “🖱️ Pick selector”, then click the image on the page.', '📤 导出为可提交的规则包片段': '📤 Export as a submittable snippet', '🐛 提交给官方(自动填好内容)': '🐛 Submit to the project (form pre-filled)', '🎯 高级:站点规则自定义': '🎯 Advanced: custom site rules', '规则明细': 'Rule details', '规则包只提供「正则 → 替换」的数据,不执行任何代码。': 'The rule pack only supplies regex → replacement data; no code is executed.', '下载后固定在本地;只有没取到本站规则、或超过一周未校验时才后台重探。': 'Pinned locally once downloaded; only rechecked when this site has no rules yet or they are over a week old.', '自动更新': 'Auto-update', '↻ 立即更新': '↻ Update now', '↩ 回滚上一版': '↩ Revert to previous', '我的规则(优先)': 'My rules (priority)', '按站点下发,不能直接改;某个站不合适,就用上面的「我的规则」盖住它。': 'Shipped per site and not editable here. If a site misbehaves, override it with “My rules” above.', '内置兜底规则': 'Built-in fallback rules', '脚本自带的通用规则,始终生效。': 'Generic rules bundled with the script — always active.', '暂无自定义规则。': 'No custom rules yet.', // —— 站点适配 —— '页面用遮罩挡住鼠标、或图片是背景图时,在这里把真正的图片容器告诉脚本。': 'When an overlay blocks the cursor or images are CSS backgrounds, tell the script the real image container here.', '为当前网站添加悬停放大规则,解决遮罩层挡住鼠标、背景图无法放大等问题。保存后刷新页面生效。': 'Add a hover-zoom rule for this site — fixes overlays blocking the cursor and non-zoomable background images. Refresh the page to apply.', '加一条容器规则': 'Add a container rule', '自动识别这类站点,避免重复触发。': 'Detects these sites automatically to avoid repeated triggers.', // —— 历史记录 —— '最近悬停看过的图片,最多 60 条,只保存在本机。': 'Recently previewed images — up to 60, stored locally only.', '最近看过': 'Recently viewed', '可回看 / 打开原图 / 复制地址;只保存在本机': 'Revisit / open original / copy URL — stored locally only', '点击收起': 'Click to collapse', '点开查看': 'Click to expand', '列表': 'List', '画廊': 'Gallery', '🗑 清空历史': '🗑 Clear history', '打开': 'Open', '复制': 'Copy', '删除这条': 'Delete this entry', '还没有。悬停放大过的图片会自动记在这里(只保存在本机,最多 60 条)。': 'Nothing yet. Images you preview will be recorded here automatically (locally only, up to 60).', '仅本地保存': 'Stored locally only', // —— 底部 —— '📘 使用说明': '📘 Guide', '📋 更新说明': '📋 Changelog', '🐞 反馈问题': '🐞 Report a problem', '🩺 复制诊断信息': '🩺 Copy diagnostics', '💾 导出': '💾 Export', '📥 导入': '📥 Import', '↺ 恢复本站默认': '↺ Reset this site', '把全部站点配置与自定义规则导出为 JSON 文件': 'Export all site settings and custom rules as JSON', '从之前导出的 JSON 文件恢复配置': 'Restore settings from a previously exported JSON file', '只重置当前网站的设置,其它网站不受影响': 'Only resets this site; other sites are unaffected', '面板中除标注「所有网站」的项外,全部只作用于当前网站': 'Everything here applies to this site only, except items marked “All sites”', // —— 表单属性 —— 'i 或 g,留空即可': 'i or g; leave empty if unsure', '例:本站缩略图后缀': 'e.g. this site’s thumbnail suffix', '如:.$1 ($1 $2 代表匹配到的内容)': 'e.g. .$1 ($1 and $2 are captured groups)', '如:.image-container-top 或 .dt-carousel-img': 'e.g. .image-container-top or .dt-carousel-img', '如:.qtd-theme-card': 'e.g. .qtd-theme-card', '如:_d+xd+.(jpg|png)': 'e.g. _d+xd+.(jpg|png)', '(可选)粘贴图片地址…': '(optional) paste an image URL…', '搜索规则 / 域名…': 'Search rules / domains…', // —— 首次介绍面板 / 使用说明 / 更新说明 —— 'HoverVista · 设置只保存在本机': 'HoverVista · settings are stored locally only', '欢迎使用': 'Welcome', '知道了': 'Got it', '知道了,开始使用': 'Got it, let’s go', '装好就能用:把鼠标停在网页图片上,稍停一下,大图自动弹出。不用点击,也不用离开当前页面。': 'Ready to use: rest the cursor on an image and the preview pops up. No clicking, no leaving the page.', '想换放大后的观感与位置:去「显示与样式」调放大模式与显示位置。': 'To change how the preview looks or sits: open “Display” and adjust zoom mode and placement.', '站首页不想启用:在「总览」开「主页不启用」,内容页不受影响。': 'Do not want it on homepages? Turn on “Homepage only” exclusion in “Overview”; inner pages keep working.', '❓ 图没反应?三步排查': '❓ No response? Three checks', '① 看右下角悬浮球是否已开启;② 太小的图(图标、按钮)会被自动跳过,属正常;③ 只有个别网站不行?打开设置用「帮我找大图」点一下那张图,按提示加条规则,再刷新页面即可。': '① Check the floating ball at the bottom-right; ② tiny images (icons, buttons) are skipped by design; ③ if only one site fails, open settings, use “Find a larger image”, click that image and add a rule, then refresh.', '⌨️ 常用快捷键(预览显示时按)': '⌨️ Common shortcuts (while the preview is shown)', '悬停图片 → 自动放大;移开鼠标即收起。预览时滚轮可缩放,图集页面用 ← → 翻页,按 z 可把整组图打包下载。右下角悬浮球随时开关本站功能。': 'Hover an image → it zooms; move away → it collapses. Use the wheel to zoom, ← → to flip through a gallery, and z to download the whole set as a ZIP. The floating ball toggles this site anytime.', '本次更新:': 'What’s new:', '全新配置面板': 'All-new settings panel', '视频悬停预览': 'Video hover preview', '三层换图规则': 'Three-tier image rules', '图集翻页与打包': 'Gallery paging & ZIP export', '历史记录 / 键位自定义': 'History & custom keymap', '配置备份与恢复': 'Config backup & restore', '规则与安全': 'Rules & security', '修复与优化': 'Fixes & polish', '网页版规则中心': 'Web rule center', '规则包签名校验': 'Rule pack signature check', '新功能': 'New', ':左侧 8 分区导航,图示化选择(放大模式 / 尺寸 / 位置 / 动画一眼对比),改动自动保存。': ': 8-section navigation with visual choices (zoom mode / size / placement / animation). Changes are saved automatically.', ':悬停页面视频或 B站 / YouTube 卡片即静音浮出播放,': ': hover a video or a Bilibili / YouTube card to play it muted,', '(重采样按倍率分级)。': ' (resampling scales with magnification).', ':我的规则 → 云端规则包 → 内置兜底,命中即停;「点图选图」自动生成规则,遮罩层 / 背景图也能放大。': ': My rules → cloud pack → built-ins, first match wins. “Pick from page” generates rules automatically, so overlays and background images zoom too.', ':屏幕居中 / 原图周围不遮挡(「显示与样式」里切换)。': ': screen center / around the image without covering it (switch in “Display”).', ':最近看过的图可回看、打开原图、复制地址(只存本机)。': ': revisit recent images, open the original, or copy the URL (local only).', ':点键帽改绑,冲突自动让出;一键恢复默认。': ': click a key cap to rebind; conflicts yield automatically; one click restores defaults.', ':导出 / 导入 JSON,换设备或重装后一键还原。': ': export / import JSON to restore everything on a new device or after reinstalling.', ':云端规则带签名与逐站哈希校验,被篡改即拒用。': ': cloud rules are signed and hash-checked per site; tampered packs are rejected.', ':ydgg123.github.io/hover-image-zoom/rules.html 可在线浏览全部站点规则。': ': browse all site rules at ydgg123.github.io/hover-image-zoom/rules.html.', ';另含显示位置两模式与重采样分级。': '; also includes two placement modes and graded resampling.', '(默认上限跟随视口)。': ' (the default cap follows the viewport).', ';宽屏自适应放大': '; adaptive zoom on wide screens', ';深层嵌套(如表格化)': '; deeply nested layouts (e.g. tables)', ';404 / 死链不再出现空白框。': '; no more empty boxes for 404s or dead links.', ';升不了的站,用「图片规则 → 帮我找大图」点一下那张图试试。': '; for stubborn sites, try “Image rules → Find a larger image” and click the image.', '面板里的设置': 'the settings in the panel', // —— 介绍卡 / 帮助里的补漏项 —— '先看这里:本站是否生效、规则命中情况,以及最常见的几个问题该去哪里调。': 'Start here: whether it works on this site, which rules matched, and where to fix the most common issues.', 's 保存高清图 · c 复制图片地址 · r 旋转 · R 水平翻转 · f 全屏 · 0 重置大小 · Esc 关闭。都能在设置的「键位」里改成顺手的键。': 's save full-res · c copy image URL · r rotate · R flip · f fullscreen · 0 reset size · Esc close. Rebind any of them under “Keys”.', '打包 ZIP;放大图下方显示尺寸、格式、来源与图说。': 'pack a ZIP; size, format, source and caption appear below the preview.', '防盗链图片自动绕过;智能高清升级按需选档,省流量。': 'Hotlink-protected images are bypassed; HD upgrade picks a tier on demand to save bandwidth.', '——去「站点适配」点一下那张图,加条规则即可。': '— open “Site fit”, click that image and add a rule.', ':规则导出后自动填好 Issue 内容。': ': the export fills the issue form for you.', '随时开/关本站放大,也能打开设置面板。': 'Toggle zoom for this site anytime, or open the settings panel.', '很多站的缩略图自带更大的原图,脚本会': 'Many sites ship a larger original behind the thumbnail — the script will', '关掉后,滚轮恢复为上下平移。': 'When off, the wheel scrolls the page instead.', '——每个网站一套,互不干扰。': '— one set per site, fully independent.', '整组打包成 ZIP 下载 ·': 'download the whole set as a ZIP ·', '低分辨率图高倍放大不再出现': 'Low-resolution images no longer break down when zoomed', ',改一次到处都是这套键。': ' — change it once and it applies everywhere.', '动作快捷键(预览显示时)': 'Action shortcuts (while shown)', '「原图周围」模式下大图': 'In “around the image” mode the preview', '旋转 / 翻转后外壳框': 'The hull frame after rotate / flip', ',停留片刻即放大预览;': ', rest a moment and the preview appears;', '缩放(或上下平移),': 'zoom (or scroll),', '一键提交规则到官方': 'Submit a rule to the project', '不再被站点样式染色': 'No longer stained by site styles', '🔒 规则包已验签': '🔒 Rule pack verified', '· 图片悬停放大': '· Image hover zoom', '只对当前网站生效': 'Applies to this site only', '复制图片地址 ·': 'copy image URL ·', '旋转 90° ·': 'rotate 90° ·', '面板文字与选中态': 'Panel text and selected state', '🖱️ 基本用法': '🖱️ Basic usage', '保存高清图 ·': 'save full-res ·', '图悬停没反应?': 'No response on hover?', '图集不再漏识别': 'Galleries no longer missed', '常见原因是页面': 'The usual cause is a page', '显示位置两模式': 'Two placement modes', '遮罩挡住了鼠标': 'overlay blocking the cursor', '切到固定倍数': 'Switch to fixed scale', '复制图片本体': 'Copy image data', '悬停任意图片': 'Hover any image', '水平翻转 ·': 'flip horizontally ·', '页面右下角的': 'the bottom-right', ':同一组图片': ': the same group of images', '找更大的图': 'Find a larger image', '不遮挡原图': 'Without covering the image', '去管理规则': 'Manage rules', '快退快进。': 'Jump forward / back.', '查看细节。': 'to see more detail.', '看得更清楚': 'See more detail', '键位自定义': 'Custom keymap', ',或图片是': ', or the image is', '不再偏小': 'No longer too small', '全屏 ·': 'fullscreen ·', '图集里:': 'In a gallery:', '基本玩法': 'Basics', '翻页 ·': 'page ·', '翻页,按': 'to page, press', '自动升级': 'HD upgrade', '跟随旋转': 'Rotates with the image', '不过滤': 'No filtering', '当前是': 'Currently', '控制球': 'Floating ball', '测试版': 'beta', '正式版': 'release', '背景图': 'Background image', '马赛克': 'Mosaic', '拖拽': 'Drag', '滚轮': 'Wheel', '鼠标': 'Mouse', '内置': 'Built-in', '配置面板': 'Settings', '版本更新说明': 'Release notes', '🎛️ 新增:': '🎛️ New: ', '▶️ 新增:': '▶️ New: ', '🖼️ 新增:': '🖼️ New: ', '🕘 新增:': '🕘 New: ', '💾 新增:': '💾 New: ', '🐛 修复:': '🐛 Fixed: ', ' —— 8 个分区、图示化选择(放大模式 / 尺寸 / 位置 / 动画一眼对比),改动自动保存。': ' — 8 sections with visual choices (zoom mode / size / placement / animation). Changes save automatically.', ' —— 悬停 B站 / YouTube 卡片或页面视频即静音浮出播放,': ' — hover a video or a Bilibili / YouTube card to play it muted,', ' 快退快进。': ' rewind / forward.', ' 翻页、按 ': ' to page, press ', ' 打包 ZIP;放大图下方还会显示尺寸、格式、来源与图说。': ' to pack a ZIP; size, format, source and caption appear below the preview.', ' —— 最近预览随时回看;13 个快捷键可视化改绑,可一键恢复默认。': ' — revisit recent previews; rebind all 13 shortcuts visually, or reset with one click.', ' —— 导出 / 导入 JSON,换设备一键还原全部站点设置。': ' — export / import JSON to restore every site setting on a new device.', '404 空框、占位图误触发、旋转后外框不跟转、面板被站点样式染色、滚轮误关预览等一批问题。另:本次新增 ': 'a batch of issues: empty 404 boxes, placeholder false-triggers, the hull not following rotation, panel styling leaking from sites, wheel closing the preview. Also added the ', ' 权限。': ' permission.', // —— 5.9.1 更新说明 / 更新弹窗 —— '🔍 新增:': '🔍 New: ', '🛡️ 新增:': '🛡️ New: ', '🇬🇧 新增:': '🇬🇧 New: ', '⚡ 改进:': '⚡ Improved: ', '背景图接入预览': 'Background-image previews', '更会找大图': 'Finds bigger images', '(尺寸 7 族 + srcset / data-* 原图)': ' (7 size families + srcset / data-* originals)', '规则包秒开': 'Instant rule packs', '英文界面': 'English interface', '历史画廊': 'History gallery', ';另含一批稳定性修复。': '; plus a batch of stability fixes.', '背景图也能放大': 'Background images zoom too', ':CSS 背景图预览一次继承全部能力——高清升级、滚轮缩放、键位、图片信息、历史记录、Esc 关闭。': ': background previews now inherit every feature — HD upgrade, wheel zoom, shortcuts, image info, history and Esc to close.', ':尺寸识别扩到 7 族(?w= / _Nw. / _NxN. / 七牛 / OSS 等),并接入网页自己声明的原图(srcset、picture、13 个 data-* 属性)。': ': size detection now covers 7 families (?w= / _Nw. / _NxN. / Qiniu / OSS etc.) and picks up page-declared originals (srcset, picture, 13 data-* attributes).', '不换成更小的图': 'Never downscale', ':反负升级闸门——候选比当前图小一律不采,宁可没有候选也不让预览变模糊。': ': anti-downgrade gate — smaller candidates are always rejected; no candidate beats a blurry preview.', '规则包更快更省': 'Faster, lighter rule packs', ':条件请求(服务端说没变就免下载)+ 国内镜像回退。': ': conditional requests (no download when unchanged) + China mirror fallback.', ':面板语言切换(跟随浏览器 / 中文 / English),中英随时来回切。': ': panel language switch (follow browser / 中文 / English), switchable anytime.', '历史记录画廊': 'History gallery', ':历史区可在「列表 / 缩略图墙」间切换,一眼找回看过的图。': ': switch the history area between list and thumbnail wall to find images at a glance.', ':翻页前静默预热相邻高清图(省流量 / 2G 自动跳过,可关闭)。': ': silently pre-warms neighbouring HD images before paging (skipped on data-saver / 2G; can be turned off).', '镜像回退,信任链不变': 'Mirror fallback, trust chain intact', ':镜像只换「字节从哪来」,签名与逐域哈希校验照旧执行。': ': mirrors only change where the bytes come from; signature and per-domain hash checks still run.', '规则包缓存修复': 'Rule pack cache fixes', ':修掉「每次开页都联网重探」与「多站共用缓存互相顶掉」。': ': fixed "re-probe on every page load" and "sites clobbering each other\'s cached pack".', '图片卡片覆盖层只能从边缘触发': 'Card overlays only triggered at the edge', ':卡片层被误判成菜单浮层已修,整张图都能正常悬停放大。': ': card overlays were misjudged as menus — fixed; the whole image triggers the preview again.', '淘宝 / 天猫图片不升级高清': 'Taobao / Tmall images not upgrading to HD', ':首页 460×460 → 1280×1280,搜索页 580×580 → 800×800。': ': home 460×460 → 1280×1280, search 580×580 → 800×800.', '超宽横幅误触发': 'Ultra-wide banner misfires', ':细长条幅(如 7680×120)不再弹预览。': ': elongated banners (e.g. 7680×120) no longer pop a preview.', '站点规则背景图延迟计时器空转已修;淘宝 / 天猫已失效的内置规则表退役。': ': fixed an idle delay timer on site-rule background images; retired the dead built-in rules for Taobao / Tmall.', '背景图也能放大了': 'Background images zoom too', ' —— CSS 背景大图与普通图能力一致:高清升级、滚轮缩放、旋转翻转、图片信息、历史记录、Esc 关闭。': ' — big CSS background images now work like normal images: HD upgrade, wheel zoom, rotate / flip, image info, history, Esc to close.', ' —— 尺寸识别扩到 7 族,并接入网页自声明的原图(srcset、data-original 等 13 个属性)。': ' — size detection covers 7 families and picks up page-declared originals (srcset, data-original and 13 attributes).', ' —— 反负升级闸门:候选比当前图小一律不采,预览不再从清晰退回模糊。': ' — anti-downgrade gate: smaller candidates are always rejected; the preview never degrades from sharp to blurry.', ' —— 条件请求 304 免下载 + 国内镜像回退;修掉每页重探与跨站规则覆盖失效。': ' — conditional requests (304 = no download) + China mirror fallback; fixed per-page re-probing and cross-site cache clobbering.', ' —— 面板语言切换(跟随浏览器 / 中文 / English),中英随时来回切。': ' — panel language switch (follow browser / 中文 / English), switchable anytime.', '图片卡片层只能从边缘触发、淘宝图不升级高清、超宽横幅误触发、背景图规则延迟计时器空转等一批问题。': 'card overlays only triggering at the edge, Taobao images not upgrading to HD, ultra-wide banner misfires, an idle delay timer on site-rule background images, and more.' }; // 带变量的文案用模式匹配(条数、域名、版本号等) const I18N_EN_PATTERNS = [ [/^·\s*(\d+)\s*条$/, (m) => '· ' + m[1]], [/^(\d+)\s*条生效$/, (m) => m[1] + ' active'], [/^(\d+)\s*条$/, (m) => m[1] + ' items'], [/^(\d+)\s*项$/, (m) => m[1] + ' items'], [/^只作用于\s*(.+)$/, (m) => 'Only on ' + m[1]], [/^已导入\s*(\d+)\s*项配置(,跳过\s*(\d+)\s*项)?\s*✅$/, (m) => 'Imported ' + m[1] + ' settings' + (m[3] ? ', skipped ' + m[3] : '') + ' ✅'], [/^已导出\s*(\d+)\s*项配置\s*📦?$/, (m) => 'Exported ' + m[1] + ' settings 📦'], [/^已是最新规则包\s*v(\S+)$/, (m) => 'Already up to date (v' + m[1] + ')'], [/^规则包已更新到\s*v(\S+)$/, (m) => 'Rule pack updated to v' + m[1] + ' 🎉'], [/^已回滚到\s*v(\S+)$/, (m) => 'Rolled back to v' + m[1]], [/^预览尺寸:(.+)$/, (m) => 'Preview size: ' + m[1]], [/^已切到\s*(.+)模式$/, (m) => 'Switched to ' + m[1] + ' mode'] ]; const I18N_ATTRS = ['title', 'placeholder', 'aria-label', 'alt']; function trText(s) { if (!s || !/[\u4e00-\u9fa5]/.test(s)) return s; const key = s.trim(); if (!key) return s; let out = I18N_EN[key]; if (out == null) { for (let i = 0; i < I18N_EN_PATTERNS.length; i++) { const m = key.match(I18N_EN_PATTERNS[i][0]); if (m) { out = I18N_EN_PATTERNS[i][1](m); break; } } } if (out == null) return s; return s.replace(key, () => out); // 函数式替换,避免替换串里的 $ 被当特殊符号 } // 就地翻译一棵子树(文本节点 + 常见文案属性)。只改能命中的节点,改完即不再命中,不会反复触发。 function applyI18nDeep(root) { if (!root || !isEnUI()) return; const texts = []; if (root.nodeType === 3) texts.push(root); else if (root.nodeType === 1 || root.nodeType === 9 || root.nodeType === 11) { const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); let n; while ((n = w.nextNode())) texts.push(n); } texts.forEach(function (t) { if (t.__hvI18n) return; const v = trText(t.nodeValue); if (v !== t.nodeValue) { t.__hvI18nOrig = t.nodeValue; t.__hvI18n = 1; t.nodeValue = v; } }); const els = []; if (root.nodeType === 1) els.push(root); if (root.querySelectorAll) root.querySelectorAll('[title],[placeholder],[aria-label],[alt]').forEach(function (e) { els.push(e); }); els.forEach(function (el) { I18N_ATTRS.forEach(function (a) { const v = el.getAttribute && el.getAttribute(a); if (!v) return; const nv = trText(v); if (nv === v) return; if (!el.__hvI18nAttrOrig) el.__hvI18nAttrOrig = {}; if (!(a in el.__hvI18nAttrOrig)) el.__hvI18nAttrOrig[a] = v; el.setAttribute(a, nv); }); }); } // 还原成中文原文(切换回中文界面时用;面板本身是整块重建,无需还原) function restoreI18nDeep(root) { if (!root || !root.querySelectorAll) { if (root && root.nodeType === 3 && root.__hvI18nOrig != null) { root.nodeValue = root.__hvI18nOrig; delete root.__hvI18nOrig; delete root.__hvI18n; } return; } const w = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null); let n; while ((n = w.nextNode())) { if (n.__hvI18nOrig != null) { n.nodeValue = n.__hvI18nOrig; delete n.__hvI18nOrig; delete n.__hvI18n; } } const els = [root]; root.querySelectorAll('[title],[placeholder],[aria-label],[alt]').forEach(function (e) { els.push(e); }); els.forEach(function (el) { const o = el.__hvI18nAttrOrig; if (!o) return; Object.keys(o).forEach(function (a) { el.setAttribute(a, o[a]); }); delete el.__hvI18nAttrOrig; }); } // 面板是动态渲染的(切分区、出提示、刷新列表都会新增节点),挂一个观察器持续补齐翻译。 function i18nWatch(root) { if (!root || !isEnUI() || root.__hvI18nWatch) return; root.__hvI18nWatch = new MutationObserver(function (muts) { muts.forEach(function (m) { if (m.type === 'characterData') applyI18nDeep(m.target); else m.addedNodes.forEach(function (n) { applyI18nDeep(n); }); }); }); root.__hvI18nWatch.observe(root, { subtree: true, childList: true, characterData: true }); } // 面板外的固定浮层(控制球、首次介绍、说明弹窗、更新弹窗)在英文界面下统一补翻译。 const I18N_ROOTS = '#zoomDockZone,#izIntroOverlay,#izHelpModal,#izUpdateNotice,.zoom-bubble-tip'; function applyI18nAll() { if (!isEnUI()) return; document.querySelectorAll(I18N_ROOTS).forEach(function (el) { applyI18nDeep(el); }); } 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) { const msg = trText(String(message)); if (showPanelCenterToast(msg, type)) return; hvToast(msg, { variant: 'mid', duration: duration, type: type }); } function showSaveToast(message, type) { const msg = trText(String(message)); if (showPanelCenterToast(msg, type)) return; hvToast(msg, { 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, // ★ 视频悬停预览(悬停视频/视频卡片时静音播放) galleryPreload: true, // ★ 图集邻图预加载(翻页前预热前后张的高清地址;省流量/2G 自动跳过) 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.galleryPreload = typeof v.galleryPreload === 'boolean' ? v.galleryPreload : 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; } // ★ 图片自身的卡片覆盖层判定:网站常把「悬停才出现的按钮层/蒙层」盖在图片上(如卡片 .card-modal)。 // 该层与光标下的图片同属一个 、且图片矩形基本被它覆盖、非 fixed 覆盖式浮层 ⇒ 允许穿透继续找图; // 真弹窗/菜单通常不在图片所属 内、或为 fixed 定位 ⇒ 仍按拦截处理。 function isImgCardCover(el, x, y) { if (!el || !el.getBoundingClientRect || !el.closest) return false; try { if (getComputedStyle(el).position === 'fixed') return false; const anchor = el.closest('a'); if (!anchor) return false; let img = null; for (const cand of anchor.querySelectorAll('img')) { if (cand.closest && cand.closest('.image-zoom-container')) continue; if (!isImgVisibleNow(cand)) continue; if (inRect(x, y, cand.getBoundingClientRect())) { img = cand; break; } } if (!img) return false; const r = el.getBoundingClientRect(), ir = img.getBoundingClientRect(); if (ir.width <= 0 || ir.height <= 0) return false; const ow = Math.max(0, Math.min(r.right, ir.right) - Math.max(r.left, ir.left)); const oh = Math.max(0, Math.min(r.bottom, ir.bottom) - Math.max(r.top, ir.top)); return (ow * oh) >= 0.8 * ir.width * ir.height; } catch (e) { return false; } } // ★ 细长条幅判定:超宽横幅 / 超窄竖条(长宽比极端、短边又很薄)。 // 这类图放大后只是把整条等比拉伸,既看不到更多细节、也没有浏览价值 ⇒ 不触发预览。 // 仅按「显示尺寸」判定;长截图(窄边很宽)与普通全景(比值不大)都不受影响。 const STRIP_MIN_RATIO = 8, STRIP_MAX_SHORT = 200; function isStripLikeImg(img) { if (!img || !img.getBoundingClientRect) return false; const r = img.getBoundingClientRect(); const short = Math.min(r.width, r.height); if (short <= 0) return false; return short <= STRIP_MAX_SHORT && Math.max(r.width, r.height) / short >= STRIP_MIN_RATIO; } // 实时资格:连接 + 样式可见 + 未被裁出可视区 + 尺寸达标 + 鼠标在实时矩形内 // 祖先锚(遮罩盖图场景):面积 ≤ 图片 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 (isStripLikeImg(img)) 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 —— 按序执行。 // 阿里系 CDN(alicdn)缩略图后缀:把「xxx.jpg_580x580q90.jpg_.webp」还原回「xxx.jpg」。 // 净化链(背景图地址)与升级链(高清还原)共用同一组步骤 —— 两处各写一份必然漂移。 const ALICDN_SUFFIX_STEPS = [ [/(_!![\w\-.,]+?\.(?:jpg|jpeg|png|webp))_[\w.\-]+$/i, '$1'], [/\.(jpg|jpeg|png|webp)_[\w.]+$/i, '.$1'], [/_\.(webp|jpg|jpeg|png)$/i, ''] ]; const URL_RULES = [ { id: 'alicdn', name: '阿里系 CDN 缩略图后缀', match: /alicdn\.com/i, loop: true, steps: ALICDN_SUFFIX_STEPS }, { 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(淘宝 / 天猫 / 1688 / 阿里图床):缩略档位写在「_580x580q90.jpg_.webp」这类后缀里, // 既不是 /s{N}x{N}_ 目录式、也不在 SIZE_FAMILIES 的尺寸指令家族里 —— 去掉后缀即还原成原图。 id: 'alicdn-orig', name: '阿里系 CDN 缩略图后缀', match: /alicdn\.com/i, loop: true, steps: ALICDN_SUFFIX_STEPS }, { // 京东:请求与预览框上限一致的 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; // 尺寸档位家族表:h = 读出当前档位,b = 按目标档位改写,g = 可选的「能否安全展开」闸门。 // 新增家族只加一行;写错也不会污染预览——每个变体都要经过真实探测,加载失败直接丢弃。 // ⚠ g 闸门防的是「改出来的 URL 比例与原图不同」→ 会让预览显示变形图,比不升级更糟: // ① 只带宽或只带高的查询参数:另一个维度不能被同时指定,否则只改一个就变了比例 // ② _NNNxNNN.ext:只有本来就是正方形(两维相等)才能等比放大 const SIZE_FAMILIES = [ { h: /\/s(\d+)x\d+_/i, b: (u, t) => u.replace(/\/s\d+x\d+_/i, '/s' + t + 'x' + t + '_') }, { h: /[?&](?:w|width)=(\d+)/i, g: (u) => !/[?&](?:h|height)=/i.test(u), b: (u, t) => u.replace(/([?&](?:w|width)=)\d+/i, (m0, p1) => p1 + t) }, { h: /[?&](?:h|height)=(\d+)/i, g: (u) => !/[?&](?:w|width)=/i.test(u), b: (u, t) => u.replace(/([?&](?:h|height)=)\d+/i, (m0, p1) => p1 + t) }, { h: /_(\d+)w\./i, b: (u, t) => u.replace(/_\d+w\./i, '_' + t + 'w.') }, { h: /_(\d+)h\./i, b: (u, t) => u.replace(/_\d+h\./i, '_' + t + 'h.') }, { h: /_(\d+)x(\d+)\.(jpe?g|png|webp)/i, g: (u, m) => m[1] === m[2], b: (u, t) => u.replace(/_(\d+)x(\d+)\.(jpe?g|png|webp)/i, (m0, p1, p2, p3) => '_' + t + 'x' + t + '.' + p3) }, { h: /[?&#\/,]imageView2?\/[^"'\s]*?\/w\/(\d+)/i, b: (u, t) => u.replace(/(\/w\/)\d+/i, (m0, p1) => p1 + t) }, { h: /[?&#,\/]w_(\d+)/i, b: (u, t) => u.replace(/([?&#,\/]w_)\d+/i, (m0, p1) => p1 + t) } ]; // 命中第一个家族;命中但闸门不放行时返回 null(视作「这张图的尺寸指令不可安全展开」) function sizeFamilyMatch(url) { for (let i = 0; i < SIZE_FAMILIES.length; i++) { const fam = SIZE_FAMILIES[i]; const m = url.match(fam.h); if (!m) continue; if (fam.g && !fam.g(url, m)) return null; return { fam: fam, m: m }; } return null; } // 从 URL 的尺寸指令读出「当前档位」;读不到 / 不可安全展开则返回 0 function sizeHintOf(url) { const hit = sizeFamilyMatch(url); if (!hit) return 0; const n = parseInt(hit.m[1], 10); return isNaN(n) ? 0 : n; } // 按命中的家族逐级展开更大档位(1200 → 1600 → 2400,只取大于当前档位的) function buildSizeVariants(url) { const hit = sizeFamilyMatch(url); if (!hit) return []; const hint = parseInt(hit.m[1], 10); if (!(hint > 0)) return []; const tiers = []; [1200, 1600, 2400].forEach((t) => { if (t > hint) tiers.push(t); }); if (!tiers.length) return []; const out = []; for (let j = 0; j < tiers.length; j++) { let v = url; try { v = hit.fam.b(url, tiers[j]); } catch (e) { continue; } if (v && v !== url && out.indexOf(v) < 0) out.push(v); } return out; } // 候选分两类: // 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); } } buildSizeVariants(url).forEach(function (v) { pushV(v); }); return { primary: primary, variants: variants }; } // ── 起点候选扩展:站点自己声明的「更大图」 ── // currentSrc 只是浏览器按视口/DPR 选中的适配档;响应式站点的 srcset 里往往还留着更大档位, // 相册/论坛/图床站则常把原图直接写在 data-* 属性里。两者都比「盲猜尺寸变体」更可信。 // ⚠ 只进备选池(hdExtra)参与探测,绝不改 fallbackSrc —— 首屏预览必须用一定显示得出来的地址。 const LAZY_SRC_ATTRS = [ // 高置信度:这些属性在相册/论坛/图床站里普遍直接写着「原图」 'data-original', 'data-origin', 'data-actualsrc', 'data-echo', 'data-url', 'data-big', 'data-large', 'data-hi-res', 'data-hires', 'data-full', 'data-zoom-image', 'data-image', // 中置信度:懒加载占位/备用源,常与 currentSrc 相同(相同即被去重丢弃) 'data-src', 'data-lazy', 'data-lazy-src', 'data-srcset' ]; function absUrl(u, base) { if (!u) return ''; if (/^https?:/i.test(u)) return u; if (/^(?:data|blob|javascript|#)/i.test(u)) return ''; try { return new URL(u, base || location.href).href; } catch (e) { return ''; } } // 解析 srcset → [{u, w, x}](w/x 为描述符数值,缺失为 0) function parseSrcset(srcset, base) { if (!srcset || typeof srcset !== 'string') return []; const list = []; srcset.split(',').forEach(function (part) { const sp = String(part).trim().split(/\s+/); if (!sp[0]) return; const u = absUrl(sp[0], base); if (!u) return; const d = sp[1] || '', mw = /^(\d+)w$/i.exec(d), mx = /^([\d.]+)x$/i.exec(d); list.push({ u: u, w: mw ? (parseInt(mw[1], 10) || 0) : 0, x: mx ? (parseFloat(mx[1]) || 0) : 0 }); }); return list; } // 「当前这张图在文件层面的像素宽」——用来判断候选是不是真的更大。 // ⚠ 不能直接拿 img.naturalWidth 当文件像素宽:图片经 srcset 选中时浏览器会按密度校正, // naturalWidth 返回的是 CSS 像素(≈ sizes 值),跟 srcset 的 w 描述符不是同一个量纲。 // 两者差一个密度系数,直接拿 naturalWidth 与 w 描述符比较会恒错。 function currentSourcePixelWidth(img, base) { const cur = img.currentSrc || img.src || ''; const nw = img.naturalWidth || 0; const sets = []; try { const pic = img.closest ? img.closest('picture') : null; if (pic) Array.prototype.forEach.call(pic.querySelectorAll('source[srcset]'), function (s) { sets.push(s.getAttribute('srcset')); }); } catch (e) { } try { sets.push(img.getAttribute('srcset')); } catch (e) { } for (let i = 0; i < sets.length; i++) { const entries = parseSrcset(sets[i], base); for (let j = 0; j < entries.length; j++) { if (entries[j].u !== cur) continue; if (entries[j].w > 0) return entries[j].w; // w 描述符就是文件像素宽 if (entries[j].x > 0 && nw > 0) return Math.round(nw * entries[j].x); // x 描述符:density = x } } return nw; // 无 srcset 时 naturalWidth 即文件像素宽 } // srcset 候选 → 按推定像素宽从大到小。已知源图文件像素宽 srcPx 时,丢掉 w 更小的档位: // 换上比源图还小的图只会更模糊,属于负升级 —— 宁可没有候选,也不要倒退。 // x 描述符量纲依赖 sizes,无法与 srcPx 可靠比较,只参与排序、不做丢弃。 function pickLargestSrcset(srcset, base, srcPx) { const entries = parseSrcset(srcset, base); const scored = []; for (let i = 0; i < entries.length; i++) { const e = entries[i]; if (srcPx > 0 && e.w > 0 && e.w < srcPx) continue; scored.push({ u: e.u, px: e.w > 0 ? e.w : (e.x > 0 ? Math.round(e.x * 1000) : 0) }); } scored.sort(function (a, b) { return b.px - a.px; }); return scored.map(function (e) { return e.u; }); } // 收集「这张 背后可能存在的更大图」:data-* 原图 → → function collectElementSources(img, limit) { const out = []; const max = Math.max(1, limit || HD_MAX_CANDIDATES); const base = (img.ownerDocument && img.ownerDocument.baseURI) || location.href; const cur = img.currentSrc || img.src || ''; const srcPx = currentSourcePixelWidth(img, base); // 源图文件像素宽:挡掉「换上更小的图」这种负升级 const push = function (u) { if (!u || u === cur || out.length >= max || out.indexOf(u) >= 0) return false; out.push(u); return true; }; const addSrcset = function (ss, cap) { const list = pickLargestSrcset(ss, base, srcPx); let n = 0; for (let i = 0; i < list.length && n < cap && out.length < max; i++) if (push(list[i])) n++; }; // 1) 懒加载 / 原图属性(每个属性最多贡献 1 个地址) for (let i = 0; i < LAZY_SRC_ATTRS.length && out.length < max; i++) { let v = null; try { v = img.getAttribute(LAZY_SRC_ATTRS[i]); } catch (e) { continue; } if (!v) continue; // 有的站把整串 srcset 塞进 data-src if (/,/.test(v) && /\s\d+[wx]\b/i.test(v)) { addSrcset(v, 2); continue; } // ★ 反负升级:单 URL 属性没有 srcset 那样的 w 描述符可比,但有些站把尺寸档直接写进了 // URL(sizeHintOf 可读)。读得出档位且比源图还小 → 直接丢,连探测请求都省了。 const one = absUrl(String(v).trim().split(/\s+/)[0], base); const hint = one ? sizeHintOf(one) : 0; if (srcPx > 0 && hint > 0 && hint < srcPx) continue; push(one); } // 2) 里的 (站点为不同条件准备的替代源) try { const pic = img.closest ? img.closest('picture') : null; if (pic) { const sources = pic.querySelectorAll('source[srcset]'); for (let i = 0; i < sources.length && out.length < max; i++) addSrcset(sources[i].getAttribute('srcset'), 2); } } catch (e) { } // 3) 自身 srcset try { addSrcset(img.getAttribute('srcset'), 2); } catch (e) { } return out; } // ── 图集邻图预加载 ── // 翻图集时的体感瓶颈是「下一张还没下载」。预览激活后静默把前后 N 张的「高清首选地址」 // 预热进浏览器缓存(只发请求,不做探测、不建 DOM),翻到时直接用缓存。 // 关闭开关 / 省流量模式 / 2G 网络会跳过;同一地址只预热一次。 const GALLERY_PRELOAD_SPAN = 2; // 前后各预热几张 const GALLERY_PRELOAD_MAX = 60; // 单页面会话最多预热多少个地址,防失控 const preloadSeen = new Set(); let preloadCount = 0; let preloadTimer = null, preloadSeq = 0; // 省流量 / 慢网:不预热(尊重系统的省流量偏好) function preloadSuppressed() { try { const c = navigator.connection || navigator.mozConnection || navigator.webkitConnection; if (!c) return false; if (c.saveData) return true; return /(^|-)2g$/.test(String(c.effectiveType || '')); } catch (e) { return false; } } function warmUrl(u) { if (!u || preloadSeen.has(u)) return false; preloadSeen.add(u); preloadCount++; try { const im = new Image(); if ('decoding' in im) im.decoding = 'async'; im.src = u; // 只借浏览器 HTTP 缓存,不持有引用 } catch (e) { } return true; } // 预热 inst 前后 span 张:有高清规则用规则地址,没有就退化成原图地址 function prefetchGalleryNeighbors(inst, span) { if (!inst || !inst.galleryList || inst.galleryList.length < 2) return 0; if (config.galleryPreload === false || preloadSuppressed()) return 0; const list = inst.galleryList; let i = list.indexOf(inst.sourceImg); if (i < 0) i = inst.galleryIndex || 0; let n = 0; for (let d = 1; d <= Math.max(1, span || GALLERY_PRELOAD_SPAN); d++) { [i + d, i - d].forEach(function (k) { if (preloadCount >= GALLERY_PRELOAD_MAX) return; const im = list[(k + list.length) % list.length]; if (!im || im === inst.sourceImg) return; const src = im.currentSrc || im.src || ''; if (!src || /^(blob|data):/i.test(src)) return; let targets = [src]; try { const c = buildHdCandidates(src); if (c && c.primary && c.primary.length) targets = c.primary; } catch (e) { } targets.slice(0, 2).forEach(function (u) { if (warmUrl(u)) n++; }); }); } return n; } // 延后一点再预热:先把当前这张显示完,别跟它抢带宽;实例已换/已收起则放弃 function scheduleGalleryPrefetch(inst, stillValid) { if (preloadTimer) { clearTimeout(preloadTimer); preloadTimer = null; } const seq = ++preloadSeq; preloadTimer = setTimeout(function () { preloadTimer = null; if (seq !== preloadSeq) return; try { if (stillValid && !stillValid()) return; } catch (e) { return; } prefetchGalleryNeighbors(inst); }, 300); } // 并行探测候选:返回 [{url, w, h}](失败的丢弃)。带整体超时,避免慢图拖住预览。 // 探测结论按「候选集合」做会话级缓存:同一张图二次悬停直接复用结论,不再重复走一遍网络。 // 命中的结果缓存较久;空结果(多为超时/网络抖动)只缓存很短时间,避免把临时失败固化下来。 const PROBE_CACHE = new Map(); const PROBE_CACHE_MAX = 240; const PROBE_TTL_HIT = 5 * 60 * 1000; const PROBE_TTL_MISS = 30 * 1000; function probeCacheKey(list) { return list.join('\n'); } function probeCacheRead(key) { const hit = PROBE_CACHE.get(key); if (!hit) return null; if (Date.now() - hit.at > hit.ttl) { PROBE_CACHE.delete(key); return null; } PROBE_CACHE.delete(key); PROBE_CACHE.set(key, hit); // 命中即置新(LRU 顺序) return hit.list; } function probeCacheWrite(key, list) { PROBE_CACHE.set(key, { at: Date.now(), ttl: list.length ? PROBE_TTL_HIT : PROBE_TTL_MISS, list: list }); while (PROBE_CACHE.size > PROBE_CACHE_MAX) PROBE_CACHE.delete(PROBE_CACHE.keys().next().value); } function probeCandidates(list, timeoutMs) { const key = probeCacheKey(list); const cached = probeCacheRead(key); if (cached) return Promise.resolve(cached.map((c) => ({ url: c.url, w: c.w, h: c.h }))); return new Promise((resolve) => { const done = []; let left = list.length; const finish = () => { const out = done.filter(Boolean); probeCacheWrite(key, out); resolve(out); }; 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; }); }); } // 择优选:优先「≥ 需求像素里最小的」,否则取最大的。 // minPx(可选):源图较长边。候选全部比它还小 → 返回 null(换上比源图更小的图属于负升级, // 只会更模糊;宁可保持源图预览,也不倒退)。不传 minPx 时行为与旧版一致。 function pickBestCandidate(loaded, needMax, minPx) { if (!loaded || !loaded.length) return null; const floor = Math.max(0, Number(minPx) || 0); const pool = floor > 0 ? loaded.filter((c) => Math.max(c.w, c.h) >= floor) : loaded; if (!pool.length) return null; const need = Math.max(1, Number(needMax) || 0); const enough = pool.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 pool.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 各自最多条数 // 本地规则包「陈旧」阈值:超过它才在启动时后台重校验一次。 // 云端规则不常变,本地已有通过验签的整包就不必每次启动联网再探一次 // ——既省流量,也让首屏悬停不必等一个网络往返。 const RULE_PACK_STALE_MS = 7 * 24 * 60 * 60 * 1000; // 缓存按域名累积(否则访问 B 站会把 A 站的规则挤掉,回到 A 站又得重下),超出上限按最久未用淘汰。 const RULE_PACK_MAX_CACHED_DOMAINS = 60; /* 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 }; // 本次页面生命周期内「向规则包取源发起过几次请求」——仅供端到端验证读,不影响行为。 let rulePackFetchCount = 0; function loadRulePackFromStorage() { rulePackState.current = storageGet(RULE_PACK_KEY, null) || null; rulePackState.meta = storageGet(RULE_PACK_META_KEY, null) || null; rebuildEffectiveRules(); } // 启动时是否需要后台重校验规则包。 // 本地已有「本站命中」且未过陈旧阈值的整包时返回 false —— 云端规则不常变, // 没必要每次开页都联网探一次;只有「本站规则从没取到过」或「太久没校验」才拉。 function rulePackNeedsRefresh() { const cur = rulePackState.current; const cached = (cur && Array.isArray(cur.domains)) ? cur.domains : []; const cachedMatch = cached.some(function (e) { return e && e.domain && packDomainMatches(currentDomain, e.domain); }); if (!cachedMatch) return true; // 本站专属规则从未取到 → 需要拉 const meta = rulePackState.meta || {}; const ts = Number(meta.revalidatedAt || meta.fetchedAt || 0); if (!ts) return true; // 没有校验时间戳(旧版写入)→ 当作陈旧 return (Date.now() - ts) > RULE_PACK_STALE_MS; } // 拉取(GM_xmlhttpRequest)→ 解析 → 校验 → 落盘 → 生效 // 说明:不使用 fetch,因为用户脚本运行环境里 GM_xmlhttpRequest 才能正确处理跨域与 @connect // 单源抓取;失败即 reject,由上层决定是否换源。 // 从 GM_xmlhttpRequest 的响应头里取 ETag;拿不到就空串(自动退化为「每次全量」的旧行为) function etagOf(headers) { if (!headers || typeof headers !== 'string') return ''; const m = /^etag:[ \t]*(.+)$/im.exec(headers); return m ? m[1].trim() : ''; } // 单源抓取;etag 非空时带 If-None-Match,源站返回 304 即 resolve({notModified:true}), // 省掉整包 index 的下行流量(index 随站点规则增长,是全量下载里最大的一块)。 function fetchRulePackOnce(url, etag) { return new Promise(function (resolve, reject) { try { if (typeof GM_xmlhttpRequest !== 'function') { reject(new Error('no-gm-xhr')); return; } rulePackFetchCount++; const headers = { 'Accept': 'application/json' }; if (etag) headers['If-None-Match'] = etag; GM_xmlhttpRequest({ method: 'GET', url: url, timeout: RULE_PACK_TIMEOUT, headers: headers, onload: function (res) { if (res.status === 304) { resolve({ notModified: true }); return; } if (res.status >= 200 && res.status < 300 && typeof res.responseText === 'string') { resolve({ text: res.responseText, etag: etagOf(res.responseHeaders) }); return; } reject(new Error('http-' + res.status)); }, ontimeout: function () { reject(new Error('timeout')); }, onerror: function () { reject(new Error('network')); } }); } catch (e) { reject(e); } }); } // 镜像回退:主源 raw.githubusercontent.com 在部分网络下不可达,依次尝试内容一致的 CDN 镜像。 // 签名与逐域哈希校验照旧执行,镜像只影响「从哪里取字节」,不影响信任链。 const RULE_PACK_MIRRORS = [ 'https://cdn.jsdelivr.net/gh/YDGG123/hover-image-zoom@main/rules/', 'https://fastly.jsdelivr.net/gh/YDGG123/hover-image-zoom@main/rules/' ]; function rulePackUrlCandidates(url) { const out = [url]; if (url.indexOf(RULE_PACK_URL_PREFIX) === 0) { const rel = url.slice(RULE_PACK_URL_PREFIX.length); RULE_PACK_MIRRORS.forEach(function (base) { out.push(base + rel); }); } return out; } function fetchRulePackText(url, etag) { const urls = rulePackUrlCandidates(url); return new Promise(function (resolve, reject) { let i = 0, lastErr = null; const attempt = function () { if (i >= urls.length) { reject(lastErr || new Error('all-sources-failed')); return; } fetchRulePackOnce(urls[i++], etag).then(resolve, function (e) { lastErr = e; attempt(); }); }; attempt(); }); } function updateRulePack(manual) { if (rulePackState.loading) return Promise.resolve({ ok: false, reason: 'busy' }); rulePackState.loading = true; // 本站是否已有规则包条目(缓存是全局的,只有「第一次取源的那个站」才有) const cachedDomains0 = (rulePackState.current && rulePackState.current.domains) || []; const cachedMatch0 = cachedDomains0.some(function (e) { return e && e.domain && packDomainMatches(currentDomain, e.domain); }); // ★ 条件请求只在本站已有条目时才用:index 返回 304 会「整包跳过」, // 而「跳过」对本站还没有规则的情况等于**永远拿不到规则**—— // 它比下面「版本相同」那条更早返回,把 cachedMatch 守卫整个绕过去了。 // 所以本站无条目时必须放弃 If-None-Match,走全量 index 再取本站文件。 const useEtag = (!manual && cachedMatch0 && rulePackState.meta && rulePackState.meta.indexEtag) || ''; const indexUrl = manual ? RULE_PACK_URL + '?t=' + Date.now() : RULE_PACK_URL; return fetchRulePackText(indexUrl, useEtag).then(async function (res) { // 304:index 未变。发得出 etag 说明本地已有可用整包,直接沿用(同「版本相同即跳过」的语义) if (res && res.notModified) { rulePackState.loading = false; const m2 = Object.assign({}, rulePackState.meta || {}, { revalidatedAt: Date.now() }); rulePackState.meta = m2; storageSet(RULE_PACK_META_KEY, m2); return { ok: true, unchanged: true, notModified: true, version: m2.version || 0 }; } const text = res && res.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 固化进缓存。 // ★「版本相同→跳过」的前提:缓存里已有【当前站点】匹配的规则。 // 缓存是全局的,domains 可能全是「别的站」的——否则 A 站更新后访问 B 站会被短路, // B 永远拉不到自己的规则;一次网络抖动也会把空 domains 固化进缓存。 const cachedMatch = cachedMatch0; if (!manual && version && prevMeta.version === version && rulePackState.current && (wanted.length === 0 || cachedMatch)) { rulePackState.loading = false; // 已经真去取过 index 并确认版本未变 —— 这就是一次「重校验」,要盖上时间戳, // 否则陈旧阈值永远判为过期,每次开页都会再探一次(与「不重复探」的意图相反)。 const m2 = Object.assign({}, prevMeta, { revalidatedAt: Date.now() }); rulePackState.meta = m2; storageSet(RULE_PACK_META_KEY, m2); 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 (r2) { const t2 = r2 && r2.text; 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 old = rulePackState.current; const oldMeta = rulePackState.meta || {}; const oldStamps = (oldMeta.domainStamps && typeof oldMeta.domainStamps === 'object') ? oldMeta.domainStamps : {}; const now = Date.now(); // ★ 跨站累积:整包只存「本站命中」的域,若直接整体覆盖,访问 B 站会把 A 站的规则挤掉, // 回到 A 站又要重下一次(还得再等一个网络往返)——与「下载完就固定在本地」相违背。 // 因此保留非本站的旧条目,本站条目用新数据覆盖;超出上限按最久未用淘汰。 const kept = ((old && Array.isArray(old.domains)) ? old.domains : []).filter(function (e) { return e && e.domain && !packDomainMatches(currentDomain, e.domain); }); let merged = kept.concat(domains); if (merged.length > RULE_PACK_MAX_CACHED_DOMAINS) { merged = merged.slice().sort(function (a, b) { return Number(oldStamps[b.domain] || 0) - Number(oldStamps[a.domain] || 0); }).slice(0, RULE_PACK_MAX_CACHED_DOMAINS); } const next = { version: version, updatedAt: String(index.updatedAt || ''), domains: merged }; // 逐域时间戳:本站刚更新过 → 记 now;保留的其他站 → 沿用旧时间戳(供陈旧判定与淘汰排序) const stamps = {}; merged.forEach(function (e) { stamps[e.domain] = packDomainMatches(currentDomain, e.domain) ? now : Number(oldStamps[e.domain] || oldMeta.fetchedAt || now); }); // ★ 回滚点:把「当前」挪到「上一版」再覆盖 if (old) storageSet(RULE_PACK_PREV_KEY, old); storageSet(RULE_PACK_KEY, next); const meta = { fetchedAt: now, source: 'remote', version: version, at: next.updatedAt, domains: merged.map(function (d) { return d.domain; }), verified: true, indexEtag: (res && res.etag) || '', domainStamps: stamps }; 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. 站点规则(背景图模式) // 内置规则表:按 domains 命中后由 setupBgRuleProxy 轮询 itemSelector,**仅对 CSS 背景图生效** // (extractBgUrl 只读内联 style / 计算样式的 background-image,不会去找容器内的 )。 // 当前无内置条目(原淘宝/天猫条目已退役)。 // 用户自定义背景规则(image_zoom_custom_rules)不受影响,且优先命中。 // 如需补回内置规则,按 { domains, itemSelector, cardSelector, pollInterval } 结构添加。 const SITE_HOVER_PROXY_RULES = []; 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; // 背景预览一旦不在显示(无论是被 Esc / 心跳 / 光标离开收掉),就允许重新进入同一张卡片时再次显示。 // 若只在「光标离开卡片」时重置 lastBgCard,则「离开又立刻回来」会因卡片不变而不再弹出。 // ★ 但「延迟显示计时器在途」时必须例外:本函数按 pollInterval 轮询,若每 tick 都把 lastBgCard // 清空,则下面 card !== lastBgCard 恒成立 → 每 tick 都 clearTimeout 掉尚未到期的显示计时器; // 当 config.delay > pollInterval(默认 800 > 300)时,该计时器永远等不到触发, // 背景图放大在本路径下永不弹出(延迟计时器被轮询饿死)。 if (!zoomFSM.isBgActive() && !bgDelayTimer) lastBgCard = null; 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, wrapper); } 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. 背景图自动识别兜底 // ★ 背景图不再自建浮层:改为把「显式图片源」交给统一的 zoomFSM / createInstance 管线。 // 这样背景图预览与 预览共享同一套能力:高清升级(含 srcset/data-* 与尺寸家族)、 // 滚轮缩放、键位快捷键(含 Esc 关闭)、图片信息栏、历史记录、以及 FSM 的状态/巡检/跨 frame 仲裁。 // 触发层(站点规则轮询 / 自动识别)只负责决定「何时显示哪张图」,不再自己画浮层。 const bgZoomLayer = (function() { let lastUrl = null; function hide() { lastUrl = null; // 只收「背景图来源」的预览:触发层会在大量与背景图无关的时机调用 hide(), // 若无条件收起,会误伤用户正在看的普通 预览。 try { if (zoomFSM.isBgActive()) zoomFSM.dispatch('HOVER_NONE', { force: true }); } catch (e) { } } // srcEl:背景图宿主元素(站点规则模式传 wrapper,自动识别传 bgEl)。它同时是 // ① 预览几何来源(源矩形 / 入场动画起点)② 保活判据(光标仍在其矩形内则保持显示)。 // zOffset:旧实现用它把背景层压在图片层之下。现在同一时刻只可能有一层(单实例 FSM), // 该参数已无需生效,仅为调用点兼容保留。 function show(loadUrls, zOffset, srcEl) { const target = loadUrls && loadUrls.cleaned; if (!target || !srcEl) return; // 普通图片预览优先级更高:背景图是兜底路径,不抢用户正在看的那张图 if (zoomFSM.hasActiveZoom() && !zoomFSM.isBgActive()) return; if (target === lastUrl && zoomFSM.isBgActive()) return; // 同一张图已在显示 lastUrl = target; zoomFSM.dispatch('SHOW', { el: srcEl, srcOpts: { srcUrl: target } }); } 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, bgEl); 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, bgEl); }, 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) && !isImgCardCover(el, x, y)) 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) && !isImgCardCover(t, x, y)) { 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; } } } // ★ 细长条幅(超宽横幅/超窄竖条):无放大意义 → 直接判为无目标。 // 在这里统一收口,路径 A~C 全覆盖;同时避免被标记成已处理图。 if (img && isStripLikeImg(img)) img = null; 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; } // srcOpts(可选):{ srcUrl } —— 给「没有 元素可读地址」的图片来源(CSS 背景图)使用。 // srcUrl 存在时:图片地址以它为准,且不参与图集(背景图没有「同组多图」概念)。 // 不传 srcOpts 时行为与旧版一致 —— 元素本身是 ,照旧读 currentSrc。 function createInstance(img, srcOpts) { 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 可能是从未请求、甚至不是图片的兜底地址。 // 背景图等「无 可读」的来源由调用方显式传入 srcUrl —— 它的优先级最高。 const rawSrc = (srcOpts && srcOpts.srcUrl) || 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, // 'bg' = 来源是 CSS 背景图(无 元素);'img' = 普通图片元素。 // 仅用于「只收起背景预览、别误伤普通预览」这类归属判断,不参与功能分支。 srcKind: (srcOpts && srcOpts.srcUrl) ? 'bg' : '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」指示) if (inst.srcKind === 'bg') { // 背景图没有「同组多图」概念。也必须跳过 collectGalleryImages: // 它会把「不在候选里的源元素」unshift 进列表,而背景图宿主是
,混进列表会污染翻页。 inst.galleryList = []; inst.galleryIndex = -1; } else { 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; })(); // 站点自声明的大图(srcset / / data-* 原图)排在备选池最前:比盲猜尺寸变体可信 const elSources = (function () { try { return collectElementSources(img, HD_MAX_CANDIDATES); } catch (e) { return []; } })(); const hdExtra = elSources.concat(hd.primary, hd.variants) .filter(function (u, i, a) { return u && a.indexOf(u) === i && 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))); // 源图较长边 = 负升级闸门下界。取法有二,按图片元素形态二选一: // ① 元素是 :currentSourcePixelWidth 给文件像素宽(srcset 场景已按 w 描述符换算), // 再按自然比例推长边(密度校正对宽高同比例,故比例可信)。 // ② 元素不是 (背景图宿主 div):只能靠预览里那张图的真实文件尺寸。 // 都取不到 → 0,闸门失效(fail-open,与本改动前行为一致)。 const sourceLongSide = (function () { try { const base = (img.ownerDocument && img.ownerDocument.baseURI) || location.href; const w = currentSourcePixelWidth(img, base); const nw = img.naturalWidth || 0, nh = img.naturalHeight || 0; if (w > 0) return (nw > 0 && nh > 0) ? Math.max(w, Math.round(w * nh / nw)) : w; const zw = zoomedImg.naturalWidth || 0, zh = zoomedImg.naturalHeight || 0; return (zw > 0 && zh > 0) ? Math.max(zw, zh) : 0; } catch (e) { return 0; } })(); const finishWith = (loaded, extra) => { if (ac && ac.signal.aborted) return; const best = pickBestCandidate(loaded, needMax, sourceLongSide); if (HV_DEBUG) { inst.hdDiag = hdCandidates.length + '候选·探成' + loaded.length + '·选' + (best ? Math.max(best.w, best.h) + 'px' : '无') + '·需' + needMax + '·源' + sourceLongSide + (extra || ''); } if (best) { applyHiRes(best.url, null); return; } // 全部直连失败(一个都没加载成功)→ 防盗链绕过(以脚本身份抓第一候选) // 注意:候选「加载成功但都比源图小」不算失败,此时保持源图预览,不做负升级。 if (!loaded.length) { gmFetchBlobUrl(hdCandidates[0]) .then((blobUrl) => applyHiRes(hdCandidates[0], blobUrl)) .catch(() => { if (HV_DEBUG) updateImageInfo(inst); /* 静默回退:保持源图预览 */ }); return; } 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(payload) { // payload 两种形态:① 裸元素(,既有调用方)② { el, srcOpts }(背景图等「无 可读地址」的来源) const el = (payload && payload.el) || payload; const srcOpts = (payload && payload.srcOpts) || null; if (!el) { hideHoverWaitIndicator(); state = S.IDLE; wheelManager.sync(); return; } // ★ 等待动画保持显示,直到该实例真正 LOADED/ACTIVE。 // 新目标:作废上一代在途任务并开启新代(ADR-002) if (currentAbort) currentAbort.abort(); currentAbort = new AbortController(); generation++; const inst = createInstance(el, srcOpts); 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; // ★ 邻图预加载:图集里把前后张的高清地址预热进缓存,翻页零等待(实例已换/已收起则自动放弃) scheduleGalleryPrefetch(inst, function () { return instance === inst && 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; }, // 当前显示的是否为「背景图来源」的预览。仅供背景图触发层判断「该不该由我来收」, // 避免它无条件收起时误伤用户正常的 预览。 isBgActive() { return !!instance && instance.srcKind === 'bg' && (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 'SHOW': { // 显式请求直接显示(不走 PENDING 等待):背景图触发层已经自己等过 config.delay actions.show(payload); 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 }; })(); // 视频悬停预览:① 悬停页面内
装好就能用:把鼠标停在网页图片上,稍停一下,大图自动弹出。不用点击,也不用离开当前页面。
🖱️ 基本用法
悬停图片 → 自动放大;移开鼠标即收起。预览时滚轮可缩放,图集页面用 ← → 翻页,按 z 可把整组图打包下载。右下角悬浮球随时开关本站功能。
⌨️ 常用快捷键(预览显示时按)
s 保存高清图 · c 复制图片地址 · r 旋转 · R 水平翻转 · f 全屏 · 0 重置大小 · Esc 关闭。都能在设置的「键位」里改成顺手的键。
❓ 图没反应?三步排查
① 看右下角悬浮球是否已开启;② 太小的图(图标、按钮)会被自动跳过,属正常;③ 只有个别网站不行?打开设置用「帮我找大图」点一下那张图,按提示加条规则,再刷新页面即可。
`; document.body.appendChild(overlay); applyI18nDeep(overlay); bindWheelTrap(overlay); const close = () => { storageSet(INTRO_SEEN_KEY, true); overlay.classList.add('anim-out'); setTimeout(() => { overlay.style.display = 'none'; overlay.classList.remove('anim-out'); }, 300); }; overlay.querySelector('#izIntroClose').addEventListener('click', close); overlay.querySelector('#izIntroOk').addEventListener('click', close); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); overlay._izClose = close; } if (!force && storageGet(INTRO_SEEN_KEY, false)) return; // 自动首次显示时立即记为已展示;手动从设置打开时不改变全局已读状态。 if (!force) storageSet(INTRO_SEEN_KEY, true); overlay.classList.remove('anim-out'); overlay.classList.add('anim-in'); overlay.style.display = 'flex'; setTimeout(() => overlay.classList.remove('anim-in'), 400); } // ★ 更新说明(全局按版本仅显示一次,不按域名保存) const UPDATE_VERSION = (typeof SCRIPT_VERSION !== 'undefined' && SCRIPT_VERSION) ? SCRIPT_VERSION : '5.6.26'; const UPDATE_SEEN_KEY = `image_zoom_update_seen_${UPDATE_VERSION}`; // ★ 使用说明 / 更新说明 弹窗(蓝色主题;内容与脚本实际行为一致) function showHelpModal(tab) { let mask = document.getElementById('izHelpModal'); if (!mask) { mask = document.createElement('div'); mask.id = 'izHelpModal'; mask.innerHTML = ` `; document.body.appendChild(mask); applyI18nDeep(mask); bindWheelTrap(mask); mask.addEventListener('click', (e) => { if (e.target === mask) mask.classList.remove('open'); }); mask.querySelector('.izn-hclose').addEventListener('click', () => mask.classList.remove('open')); mask.querySelector('#izHelpOk').addEventListener('click', () => mask.classList.remove('open')); mask.querySelector('#izHelpGoRules').addEventListener('click', () => { mask.classList.remove('open'); toggleConfigPanel(); }); mask.querySelectorAll('.izn-htabs button').forEach(b => b.addEventListener('click', () => { mask.querySelectorAll('.izn-htabs button').forEach(x => x.classList.toggle('on', x === b)); mask.querySelectorAll('.izn-htab').forEach(p => p.classList.toggle('on', p.dataset.tab === b.dataset.tab)); })); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && mask.classList.contains('open')) mask.classList.remove('open'); }); } mask.querySelectorAll('.izn-htabs button').forEach(x => x.classList.toggle('on', x.dataset.tab === tab)); mask.querySelectorAll('.izn-htab').forEach(p => p.classList.toggle('on', p.dataset.tab === tab)); applyI18nDeep(mask); mask.classList.add('open'); } function showUpdateNotice() { if (storageGet(UPDATE_SEEN_KEY, false)) return; let notice = document.getElementById('izUpdateNotice'); if (!notice) { notice = document.createElement('div'); notice.id = 'izUpdateNotice'; notice.innerHTML = `
✨
版本更新说明
v${UPDATE_VERSION}
🇬🇧 新增:英文界面 —— 面板语言切换(跟随浏览器 / 中文 / English),中英随时来回切。
🐛 修复:图片卡片层只能从边缘触发、淘宝图不升级高清、超宽横幅误触发、背景图规则延迟计时器空转等一批问题。
`; document.body.appendChild(notice); applyI18nDeep(notice); const close = () => { storageSet(UPDATE_SEEN_KEY, true); notice.classList.add('anim-out'); setTimeout(() => { if (!notice) return; notice.style.display = 'none'; notice.classList.remove('anim-out'); }, 250); }; notice.querySelector('#izUpdateClose').addEventListener('click', close); notice.querySelector('#izUpdateOk').addEventListener('click', close); notice._izClose = close; } storageSet(UPDATE_SEEN_KEY, true); notice.classList.remove('anim-out'); notice.classList.add('anim-in'); notice.style.display = 'block'; setTimeout(() => notice.classList.remove('anim-in'), 400); } // 配置面板 UI // Config Panel UI module(保留原有配置面板代码,不改运行行为) const COMMON_PARAM_DEFS = [ { key: 'delay', label: '停留多久才弹出', unit: 'ms', min: 0, max: 2000, step: 100, tip: '越小越灵敏,越大越不容易误触发。', zeroText: '立刻弹出' }, { key: 'minOriginalSize', label: '太小的图不放大', unit: 'px', min: 0, max: 500, step: 5, tip: '图标、表情、按钮一般小于 100px。', zeroText: '不过滤' }, { key: 'maxWidth', label: '大图最大宽度', unit: 'px', min: 300, max: 3000, step: 100, tip: '图片始终保持原始比例。', markText: '跟随视口' }, { key: 'maxHeight', label: '大图最大高度', unit: 'px', min: 300, max: 3000, step: 100, tip: '与最大宽度一起限制大图的尺寸。', markText: '跟随视口' }, { key: 'scrollSpeed', label: '滚轮平移距离', unit: 'px', min: 5, max: 50, step: 1, tip: '内容超出屏幕时,滚轮每次移动的距离。' } ]; const FIXED_PARAM_DEFS = [ { key: 'scale', label: '放大倍数', unit: '×', min: 1, max: 5, step: 0.1, tip: '相对原图的放大倍数。' }, { key: 'minScale', label: '最小不小于', unit: '×', min: 1, max: 3, step: 0.1, tip: '再小也不会小于这个倍数。' }, { key: 'smallImgThreshold', label: '多大的图算小图', unit: 'px', min: 100, max: 500, step: 10, tip: '显示尺寸小于这个值的,按小图处理。' }, { key: 'smallImgWidth', label: '小图预览宽度', unit: 'px', min: 300, max: 1000, step: 10, tip: '小图放大后的宽度基准。' }, { key: 'smallImgHeight', label: '小图预览高度', unit: 'px', min: 300, max: 1000, step: 10, tip: '小图放大后的高度基准。' } ]; // 「预览尺寸」四档:把 maxWidth/maxHeight 合并成一个直观选择,仍写回原字段(导出格式兼容);精确宽高在「高级」保留。 const SIZE_STEPS = [ { w: 800, h: 600, name: '小' }, { w: 1400, h: 1050, name: '中' }, { w: 2200, h: 1650, name: '大' }, { w: 3000, h: 3000, name: '跟随视口' } ]; function sizeStepIndex() { for (let i = 0; i < SIZE_STEPS.length; i++) { if (config.maxWidth === SIZE_STEPS[i].w && config.maxHeight === SIZE_STEPS[i].h) return i; } return -1; // 已用精确滑块自定义 } // 档位 → 占「视口可用空间」的比例:小视口下若按 min(视口, 档位绝对尺寸) 取上限,中/大/跟随会撞同一上限失去区分。 const STEP_VIEWPORT_SCALE = [0.55, 0.72, 0.88, 1.0]; // 小 / 中 / 大 / 跟随视口 function stepViewportScale() { const i = sizeStepIndex(); return i >= 0 ? STEP_VIEWPORT_SCALE[i] : 1.0; // 自定义滑块 → 不缩档(用绝对尺寸) } // 参数保存提示已由 showToast / showSaveToast 统一路由到面板中央 const notifyConfigSaved = debounce((key, value, label) => { showSaveToast(`已保存:${label || key} = ${value}`); }, 600); function injectCustomRulesSection(overlay) { const scroll = overlay.querySelector('#izSiteFitMount'); if (!scroll || scroll.querySelector('#izCustomRulesSection')) return; const section = document.createElement('div'); section.className = 'iz-section'; section.id = 'izCustomRulesSection'; const renderList = () => { const rules = getCustomRules(); if (!rules.length) return '
暂无自定义规则。
'; return rules.map(r => `
${escapeHtml(r.name)} · ${escapeHtml(r.domains)}
${r.imgMode === 'img' ? 'IMG' : '背景图'} | ${escapeHtml(r.itemSelector)} | ${escapeHtml(r.cardSelector || '无卡片选择器')}
`).join(''); }; section.innerHTML = `
🎯 高级:站点规则自定义 仅本地保存
为当前网站添加悬停放大规则,解决遮罩层挡住鼠标、背景图无法放大等问题。保存后刷新页面生效。
${renderList()}
`; scroll.appendChild(section); const listEl = section.querySelector('#izRuleList'); const form = section.querySelector('#izRuleForm'); let lastSavedRule = null; section.querySelector('#izRuleAddBtn').addEventListener('click', () => { form.style.display = 'block'; section.querySelector('#izRuleDomains').value = currentDomain; }); // ===== 拾取模式(加固版:全屏遮罩拦截事件,站点收不到任何鼠标事件)===== section.querySelector('#izRulePickBtn').addEventListener('click', () => { form.style.display = 'block'; const ov0 = document.getElementById('izModalOverlay'); if (ov0) ov0.style.display = 'none'; const tips = document.createElement('div'); tips.style.cssText = 'position:fixed;z-index:2147483647;background:rgba(15,23,42,.92);color:#fff;padding:8px 12px;border-radius:8px;font:12px monospace;pointer-events:none;max-width:420px;display:none;white-space:nowrap;'; document.body.appendChild(tips); const bar = document.createElement('div'); bar.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#4F46E5;color:#fff;padding:8px 20px;border-radius:20px;font-size:13px;font-weight:600;'; bar.textContent = '🎯 点击图片附近任意位置(遮罩/标题也行),自动定位图片容器,按 ESC 取消'; document.body.appendChild(bar); // ★ 全屏遮罩:所有鼠标事件落在遮罩上,站点自身的点击处理器(灯箱/加载层)不会被触发 const blocker = document.createElement('div'); blocker.style.cssText = 'position:fixed;inset:0;z-index:2147483646;cursor:crosshair;background:transparent;'; document.body.appendChild(blocker); const shortSel = (el) => { if (!el || el === document.body || el === document.documentElement) return null; if (el.tagName === 'IMG') return 'img'; if (el.id) { const s0 = '#' + CSS.escape(el.id); if (document.querySelectorAll(s0).length <= 60) return s0; } for (const c of (el.classList || [])) { const s0 = '.' + CSS.escape(c); if (document.querySelectorAll(s0).length > 0) return s0; } return el.tagName.toLowerCase(); }; const deriveSelectors = (hitEl) => { // 拾取器只负责"背景图"场景。★ IMG 元素不算背景图容器 let bgEl = null, node = hitEl; while (node && node !== document.body) { if (node.tagName === 'IMG') { node = node.parentElement; continue; } try { const bg = getComputedStyle(node).backgroundImage; if (bg && bg !== 'none' && bg.includes('url(')) { bgEl = node; break; } } catch (e) { } if (node.querySelectorAll) { const kids = node.querySelectorAll('*'); if (kids.length <= 800) { for (const kid of kids) { if (kid.tagName === 'IMG') continue; try { const bg2 = getComputedStyle(kid).backgroundImage; if (bg2 && bg2 !== 'none' && bg2.includes('url(')) { const kr = kid.getBoundingClientRect(); if (kr.width >= 80 && kr.height >= 80) { bgEl = kid; break; } } } catch (e) { } } if (bgEl) break; } } node = node.parentElement; } if (!bgEl) return { imgMode: 'img' }; if (bgEl.tagName === 'IMG') return { imgMode: 'img' }; // 双保险 const br = bgEl.getBoundingClientRect(); if (br.width < 80 || br.height < 80) return { imgMode: 'img' }; const itemSel = shortSel(bgEl); let card = null; node = bgEl.parentElement; while (node && node !== document.body) { const r = node.getBoundingClientRect(); if (r.width < window.innerWidth * 0.9 && r.height < window.innerHeight * 0.9 && (node.querySelector('a') || node.querySelector('p'))) { card = node; break; } node = node.parentElement; } let ok = false, count = 0; try { count = document.querySelectorAll(itemSel).length; ok = count > 0; } catch (e) { } return ok ? { itemSel, cardSel: card ? shortSel(card) : '', count, mode: 'background' } : { imgMode: 'img' }; }; const cleanupPicker = () => { blocker.removeEventListener('mousemove', move, true); blocker.removeEventListener('click', pick, true); document.removeEventListener('keydown', esc, true); blocker.remove(); tips.remove(); bar.remove(); }; const move = (e) => { // 暂时藏起遮罩,让 elementFromPoint 穿透到页面真实元素 blocker.style.display = 'none'; const el = document.elementFromPoint(e.clientX, e.clientY); blocker.style.display = 'block'; if (!el) return; const d = deriveSelectors(el); if (!d || d.imgMode === 'img') { tips.style.display = 'block'; tips.style.left = Math.min(e.clientX + 16, window.innerWidth - 440) + 'px'; tips.style.top = (e.clientY + 16) + 'px'; tips.textContent = '✓ 普通图片无需自定义规则(脚本已自动支持遮罩盖图场景),请点击背景图类卡片'; tips.dataset.item = ''; tips.dataset.card = ''; return; } tips.style.display = 'block'; tips.style.left = Math.min(e.clientX + 16, window.innerWidth - 440) + 'px'; tips.style.top = (e.clientY + 16) + 'px'; tips.textContent = '图片容器: ' + d.itemSel + '(含图 ' + d.count + ' 张)' + (d.cardSel ? ' | 卡片: ' + d.cardSel : ''); tips.dataset.item = d.itemSel; tips.dataset.card = d.cardSel || ''; }; const esc = (e) => { if (e.key === 'Escape') cleanupPicker(); }; const pick = (e) => { const itemSel = tips.dataset.item, cardSel = tips.dataset.card; cleanupPicker(); if (!itemSel) { showToast('普通图片无需自定义规则,脚本已自动支持(仅背景图卡片需要规则)'); const ov = document.getElementById('izModalOverlay'); if (ov) { ov.classList.add('anim-in'); ov.style.display = 'flex'; setTimeout(() => ov.classList.remove('anim-in'), 400); } return; } section.querySelector('#izRuleItem').value = itemSel; section.querySelector('#izRuleCard').value = cardSel; showSaveToast('已拾取:' + itemSel + ',确认后保存'); const ov = document.getElementById('izModalOverlay'); if (ov) { ov.classList.add('anim-in'); ov.style.display = 'flex'; setTimeout(() => ov.classList.remove('anim-in'), 400); } }; document.addEventListener('keydown', esc, true); blocker.addEventListener('mousemove', move, true); blocker.addEventListener('click', pick, true); }); section.querySelector('#izRuleCancel').addEventListener('click', () => { form.style.display = 'none'; }); section.querySelector('#izRuleSave').addEventListener('click', () => { const itemSelector = section.querySelector('#izRuleItem').value.trim(); if (!itemSelector) { showToast('请填写图片容器选择器'); return; } try { document.querySelector(itemSelector); } catch (e) { showToast('选择器语法有误,请检查'); return; } // 只允许背景图规则:校验目标元素(或其子元素)有 background-image let isBgSel = false; try { const els = document.querySelectorAll(itemSelector); for (const el of els) { if ((getComputedStyle(el).backgroundImage || '').includes('url(')) { isBgSel = true; break; } if (el.querySelector && [...el.querySelectorAll('*')].slice(0, 50).some(k => (getComputedStyle(k).backgroundImage || '').includes('url('))) { isBgSel = true; break; } } } catch (e) { } if (!isBgSel) { showToast('该选择器未匹配到背景图元素。普通图片无需规则(脚本已自动支持)'); return; } const rules = getCustomRules(); const newRule = { id: 'r' + Date.now(), name: section.querySelector('#izRuleName').value.trim() || currentDomain, domains: section.querySelector('#izRuleDomains').value.trim() || currentDomain, imgMode: 'background', itemSelector, cardSelector: section.querySelector('#izRuleCard').value.trim(), pollInterval: 300, enabled: true }; rules.push(newRule); saveCustomRules(rules); lastSavedRule = newRule; listEl.innerHTML = renderList(); form.style.display = 'none'; showSaveToast('规则已保存,刷新页面后生效'); }); listEl.addEventListener('click', (e) => { const item = e.target.closest('.iz-rule-item'); if (!item) return; const rules = getCustomRules(); const idx = rules.findIndex(r => r.id === item.dataset.id); if (idx < 0) return; if (e.target.classList.contains('iz-rule-del')) { rules.splice(idx, 1); saveCustomRules(rules); listEl.innerHTML = renderList(); showSaveToast('规则已删除'); } else if (e.target.classList.contains('iz-rule-enabled')) { rules[idx].enabled = e.target.checked; saveCustomRules(rules); } }); } function createConfigPanel() { const overlay = document.createElement('div'); overlay.id = 'izModalOverlay'; // ★ 数值参数一律「滑块 + 数字框」: // 滑块负责探索(区间可见、可拖着看效果),数字框负责精确输入(保持既有能力不丢)。 // zeroText / markText 用于在滑杆两端标出语义(如 0 = 不过滤、3000 = 跟随视口)。 const sliderFill = (v, min, max) => { const p = max > min ? Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100)) : 0; return p.toFixed(2) + '%'; }; const renderParams = (defs) => defs.map(p => { const v = config[p.key]; return `
${p.unit}
${(p.zeroText || p.markText) ? `
${p.zeroText || ''}${p.markText || ''}
` : ''}
`; }).join(''); // ★ 分段控件:2–4 个选项时替代下拉框(选项一眼看全、一次点中) // cur 可显式指定选中值(用于「预览尺寸」这种不直接对应 config 字段的派生状态) const renderSeg = (key, opts, cur) => { const active = (cur === undefined) ? config[key] : cur; return `
${opts.map(o => ``).join('')}
`; }; // ★ 预览尺寸档位:每档画一个「视口框 + 按比例缩放的方块」, // 方块大小直接表达「大图能有多大」,比四个文字按钮直观得多。 // (用字符串拼接而非模板字符串,避免与外层 innerHTML 的反引号冲突) const sizeStepHtml = SIZE_STEPS.map((s, i) => { // 用「宽度比例」而非 min(宽比,高比):预览区高度总是宽度的 0.6–0.8 倍, // 取 min 会让方块尺寸实际由高度决定,四档被压成 27/40/60/100,差异不明显。 // 改用宽度比例后是 33/53/80/100,档位差距一眼可辨。 const pct = Math.round((s.w / 3000) * 100); const bw = Math.max(28, pct); const bh = Math.max(26, Math.round(pct * 0.85)); const on = sizeStepIndex() === i ? ' on' : ''; return ''; }).join(''); // 通用参数按语义拆分到不同分区(每项仍是同一个 config 字段) const DISPLAY_SIZE_DEFS = COMMON_PARAM_DEFS.filter(p => p.key === 'maxWidth' || p.key === 'maxHeight'); const TRIGGER_BASIC_DEFS = COMMON_PARAM_DEFS.filter(p => p.key === 'delay' || p.key === 'minOriginalSize'); const SCROLL_DEFS = COMMON_PARAM_DEFS.filter(p => p.key === 'scrollSpeed'); function buildPanelHTML() { return `
🔍
悬景HoverVista
当前网站 ${currentDomain}
改动已自动保存
总览与诊断

先看这里:本站是否生效、规则命中情况,以及最常见的几个问题该去哪里调。

当前网站状态只作用于 ${currentDomain}
启用图片放大
关闭后本网站的悬停放大与视频预览全部停用(其它网站不受影响)。等效于点击页面上的控制球。
当前主页已启用图片放大仅主页
站首页通常图片密集、容易误触发;开启后只关首页,内容子页面照常生效。
规则与数据概况
我的规则 0 条
云端规则包与本站命中情况见「图片规则」
云端规则包 读取中
🔒 签名校验状态与更新入口在「图片规则」
界面语言只影响显示文案
属全局设置,所有网站共用。
遇到问题?点一下直达对应设置
显示与样式

决定大图「多大、出现在哪、长什么样」。

放大模式方块宽度 = 大图宽度
大图能有多大方块大小就是大图大小
${sizeStepHtml}
高级:分别设置宽高
${renderParams(DISPLAY_SIZE_DEFS)}
大图出现在哪看动画对比
出现方式看动画对比
系统开启「减少动态效果」时会自动降级为直接淡入。
外观
显示图片信息栏
在大图下方显示尺寸、格式、来源和图说。
触发与交互

什么时候弹出、怎么操作它,以及和页面点击放大的相处方式。

触发时机
${renderParams(TRIGGER_BASIC_DEFS)}
避开「点一下才放大」的网站
自动识别这类站点,避免重复触发。
切到别的应用时自动收起
关掉后,切换应用时预览会保留。
滚轮与视频
${renderParams(SCROLL_DEFS)}
滚轮放大缩小
关掉后,滚轮恢复为上下平移。
悬停视频时预览播放
悬停视频卡片时静音浮出播放,按 Esc 关闭。
图集邻图预加载
翻页前先预热前后张,翻起来更顺;省流量模式与 2G 网络会自动跳过。
B站全屏音量修正
B站全屏时如果滚轮调不了音量,开启这项即可。
固定模式参数

这些设置只在「固定倍数」模式下生效

${renderParams(FIXED_PARAM_DEFS)}
键位

只在预览显示时生效。键位对所有网站通用,改一次到处都是这套键。

键位对所有网站通用
换图规则

从上到下依次生效:① 我的规则 → ② 云端规则包 → ③ 内置兜底。

▶规则明细读取中
① 我的规则(优先)→ ② 云端规则包 → ③ 内置兜底
点击收起
读取中…
🔍 帮我找大图
图片看不清?点「点图选图」后,直接在网页上点那张图就行。
1我的规则 优先
这里加的规则会盖过云端和内置规则。
生效范围
高级:手写匹配规则 / 导出
匹配选项(一般不用改)
2 云端规则包 读取中
按站点下发,不能直接改;某个站不合适,就用上面的「我的规则」盖住它。
自动更新
下载后固定在本地;只有没取到本站规则、或超过一周未校验时才后台重探。
读取中…
规则包只提供「正则 → 替换」的数据,不执行任何代码。
3 内置兜底规则
脚本自带的通用规则,始终生效。
站点适配

页面用遮罩挡住鼠标、或图片是背景图时,在这里把真正的图片容器告诉脚本。

历史记录

最近悬停看过的图片,最多 60 条,只保存在本机。

▶最近看过
可回看 / 打开原图 / 复制地址;只保存在本机
点击收起
`; } overlay.innerHTML = buildPanelHTML(); document.body.appendChild(overlay); applyI18nDeep(overlay); // 英文界面:首屏文案就地翻译 i18nWatch(overlay); // 之后动态渲染的节点(切分区、刷新列表、提示)持续补齐 bindWheelTrap(overlay); injectCustomRulesSection(overlay); const $ = (id) => overlay.querySelector('#' + id); const conflictToggle = $('izConflictToggle'); const modeBadge = $('izModeBadge'); const biliToggle = $('izBiliToggle'); // 面板级 document/window 监听统一由它管理:overlay 被移除(导入配置后整块重建)时一次性注销,避免重复叠加 const panelAbort = new AbortController(); // ==== 供模板与各分区复用的函数 / 元素引用(原顶层声明,跨分节共享) ==== const wireCollapseA11y = (header, body) => { const syncExp = () => header.setAttribute('aria-expanded', body.classList.contains('open') ? 'true' : 'false'); new MutationObserver(syncExp).observe(body, { attributes: true, attributeFilter: ['class'] }); syncExp(); header.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); header.click(); } }); }; function renderUrlRules() { const list = $('izUrlRuleList'); if (!list) return; const rules = (config.userUrlRules || []); $('izUrlRuleCount').textContent = rules.length ? ('· ' + rules.length + ' 条') : ''; if (!rules.length) { list.innerHTML = '
还没有。用上面的「帮我找大图」就能加一条,不用懂任何技术。
'; return; } list.innerHTML = rules.map(function (r, i) { return '
' + '' + '
' + escapeHtml(r.label || ('规则 ' + (i + 1))) + '
' + '
' + (r.enabled !== false ? '生效中' : '已停用') + ' · ' + (r.phase === 'clean' ? '清理' : '找大图') + ' · ' + (r.scope === 'global' ? '所有网站' : ('仅 ' + escapeHtml(r.domain || '本站'))) + '
' + '' + '
'; }).join(''); } function commitUrlRules() { saveConfig(); rebuildEffectiveRules(); renderUrlRules(); renderUrlRuleStatus(); } // 新规则的生效范围:默认「只在当前网站」(记下 domain),可选「所有网站」 function segValue(segId, fallback) { const box = $(segId); if (!box) return fallback; const on = box.querySelector('button.on'); return on ? on.dataset.val : fallback; } function readRuleScope() { if (segValue('izUrlScope', 'site') === 'global') return { scope: 'global', domain: '' }; return { scope: 'site', domain: currentDomain }; } // 「规则在当前网站生效了吗」——生效规则数、来源构成、最近一次真实命中 function renderUrlRuleStatus() { const en = isEnUI(); const st = $('izUrlRuleStatus'), note = $('izUrlRuleNote'); if (!st || !note) return; const userN = (config.userUrlRules || []).filter(function (r) { return r.enabled !== false; }).length; const pack = rulePackState.current; const hits = ((pack && pack.domains) || []).filter(function (d) { return packDomainMatches(currentDomain, d.domain); }); const packN = hits.reduce(function (a, d) { return a + ((d.hd || []).length + (d.clean || []).length); }, 0); const builtinN = URL_RULES.length + HD_UPGRADE_RULES.length; // 顶部折叠头计数 + ③ 内置兜底条数(合并成单一「图片规则」区后新增) const cntEl = $('izRuleCount'); if (cntEl) cntEl.textContent = en ? ((userN + packN + builtinN) + ' active') : ((userN + packN + builtinN) + ' 条生效'); const biEl = $('izBuiltinCount'); if (biEl) biEl.textContent = builtinN + (en ? ' items' : ' 条'); st.textContent = en ? (currentDomain + ': ' + (userN + packN + builtinN) + ' rules active (① mine ' + userN + ' / ② cloud ' + packN + ' / ③ built-in ' + builtinN + ')') : (currentDomain + ':生效 ' + (userN + packN + builtinN) + ' 条规则' + '(① 我的 ' + userN + ' / ② 云端 ' + packN + ' / ③ 内置 ' + builtinN + ')'); const parts = []; if (lastRuleHit) { const f = String(lastRuleHit.from), t = String(lastRuleHit.to); const tail = function (s) { return s.length > 68 ? '…' + s.slice(-68) : s; }; parts.push((en ? '✅ Last real match [' : '✅ 最近一次实际命中【') + (lastRuleHit.name || lastRuleHit.id) + (en ? '] ' : '】 ') + tail(f) + (en ? ' → ' : ' → ') + tail(t)); } else { parts.push(en ? '⏳ No rule has matched on this page yet — hover an image and come back.' : '⏳ 本次页面还没命中过规则:悬停一张图片后回到这里看结果。'); } if (hits.length) parts.push((en ? 'Rule pack for this site: ' : '规则包为本站提供:') + hits.map(function (d) { return d.label || d.domain; }).join(en ? ', ' : '、')); else parts.push(en ? 'Rule pack has no site-specific rules (using built-in rules).' : '规则包暂无本站专属规则(使用内置规则)。'); note.textContent = parts.join(en ? ' | ' : ' | '); } // ===== 历史记录 ===== function fmtHistTime(ts) { if (!ts) return ''; const d = new Date(ts), now = new Date(); const pad = n => (n < 10 ? '0' + n : '' + n); const hm = pad(d.getHours()) + ':' + pad(d.getMinutes()); if (d.toDateString() === now.toDateString()) return (isEnUI() ? 'Today ' : '今天 ') + hm; return (d.getMonth() + 1) + '-' + d.getDate() + ' ' + hm; } // 历史视图:列表 / 画廊(缩略图墙)。属全局偏好,跨网站共用。 function histGalleryOn() { return globalPrefs.historyGallery === true; } function renderHistory() { const list = $('izHistoryList'); if (!list) return; const items = getHistory(); const cnt = $('izHistoryCount'); if (cnt) cnt.textContent = items.length ? ('· ' + items.length + ' 条') : ''; const viewSeg = $('izHistoryView'); if (viewSeg) viewSeg.querySelectorAll('button').forEach(function (b) { b.classList.toggle('on', (b.dataset.val === 'gallery') === histGalleryOn()); }); if (!items.length) { list.innerHTML = '
还没有。悬停放大过的图片会自动记在这里(只保存在本机,最多 60 条)。
'; return; } if (histGalleryOn()) { list.innerHTML = '
' + items.map(function (r) { const dim = (r.w > 0 && r.h > 0) ? (r.w + '×' + r.h) : ''; const meta = [r.host, dim, fmtHistTime(r.ts)].filter(Boolean).join(' · '); const thumb = r.thumb || r.u; return '
' + '' + '
' + '' + '' + '' + '
'; }).join('') + '
'; // 缩略图加载失败直接隐藏(不写内联 onerror,避免被站点 CSP 拦掉) list.querySelectorAll('.iz-hist-cell > img').forEach(function (im) { im.addEventListener('error', function () { im.style.visibility = 'hidden'; }); }); return; } list.innerHTML = items.map(function (r) { const dim = (r.w > 0 && r.h > 0) ? (r.w + '×' + r.h) : ''; const meta = [r.host, dim, fmtHistTime(r.ts)].filter(Boolean).join(' · '); const thumb = r.thumb || r.u; return '
' + '' + '
' + escapeHtml(r.u) + '
' + '
' + escapeHtml(meta) + '
' + '
' + '' + '' + '' + '
'; }).join(''); } function updateDetailState() { const isFixed = config.zoomMode === 'fixed'; modeBadge.textContent = isFixed ? '以下 5 项参数参与计算' : '这些设置只在「固定倍数」模式下生效'; const lock = $('iznFixedLock'), card = $('iznFixedCard'); if (lock) lock.hidden = isFixed; if (card) card.classList.toggle('izn-locked', !isFixed); overlay.querySelectorAll('.iz-slider-row').forEach(row => { const off = FIXED_PARAM_DEFS.some(p => p.key === row.dataset.param) && !isFixed; row.querySelectorAll('input').forEach(i => { i.disabled = off; }); row.classList.toggle('disabled-group', off); }); } // ===== 数值参数:滑块 + 数字框 ===== // 滑块拖动时只改内存 + 界面(避免高频写存储),松手(change)才落盘并提示; // 数字框保留精确输入能力,回车或失焦生效。两者始终双向同步。 function paintSlider(sl) { const mn = parseFloat(sl.min), mx = parseFloat(sl.max), v = parseFloat(sl.value); const p = mx > mn ? Math.max(0, Math.min(100, ((v - mn) / (mx - mn)) * 100)) : 0; sl.style.setProperty('--fill', p.toFixed(2) + '%'); } function markModified(key, val) { const item = overlay.querySelector('.iz-param-item[data-mod-key="' + key + '"]'); if (item) item.classList.toggle('mod', Number(val) !== Number(defaultConfig[key])); } function syncParamControls(key, val, except) { overlay.querySelectorAll('[data-param="' + key + '"]').forEach(el => { if (el.tagName !== 'INPUT' || el === except) return; // 只同步输入控件(slider-row 容器也带 data-param) if (String(el.value) !== String(val)) el.value = val; }); overlay.querySelectorAll('.iz-slider[data-param="' + key + '"]').forEach(paintSlider); markModified(key, val); } // ★ 放大模式:两张动画大卡(不再是下拉/分段,因为这是决定全局行为的关键选项) function syncZoomModes() { const box = $('iznZoomModes'); if (!box) return; const cur = config.zoomMode === 'fixed' ? 'fixed' : 'adaptive'; box.querySelectorAll('.izn-mode').forEach(b => b.classList.toggle('on', b.dataset.mode === cur)); } // ★ 预览尺寸:4 档方块(方块大小 = 大图大小) function syncSizeStep() { const box = $('iznSizeSteps'); if (!box) return; const idx = sizeStepIndex(); box.querySelectorAll('.izn-size').forEach(b => b.classList.toggle('on', String(b.dataset.val) === String(idx))); } // 显示位置(center/around)两卡片 function syncPlaces() { const cur = config.previewPlacement || 'center'; overlay.querySelectorAll('#iznPlaces .izn-place').forEach(p => p.classList.toggle('on', p.dataset.place === cur)); } // 出现方式(dock/spotlight/fade)三卡片 function syncTransitions() { const cur = config.previewTransition || 'dock'; overlay.querySelectorAll('#iznTransitions .izn-place').forEach(p => p.classList.toggle('on', p.dataset.fx === cur)); } function iznSync() { try { const master = $('iznMasterWrap'); if (master) { master.setAttribute('aria-checked', String(isEnabled)); const tg = master.querySelector('.iz-toggle'); if (tg) tg.classList.toggle('active', isEnabled); } const rs = $('iznRuleSummary'); if (rs) rs.textContent = (config.userUrlRules || []).filter(r => r.enabled !== false).length + ' 条'; const ps = $('iznPackSummary'); if (ps) { const m = (typeof rulePackState !== 'undefined' && rulePackState.meta) || {}; ps.textContent = m.version ? ('v' + m.version) : '未加载'; } const ht = $('iznHistTag'); if (ht) { try { const h = storageGet('hvHistoryV1', []); ht.textContent = Array.isArray(h) ? h.length : 0; } catch (e) { } } } catch (e) { } } // 面板控件「单一同步入口」:把每个控件校正为「当前 config + isEnabled + 哔哩开关」的真实状态; // 「打开面板」与「恢复默认」都走这里。 function syncPanelFromState() { try { syncZoomModes(); } catch (e) { } try { conflictToggle.classList.toggle('active', config.avoidClickConflict); } catch (e) { } const bt = $('izBlurDismissToggle'); if (bt) bt.classList.toggle('active', config.blurDismiss); const wt = $('izWheelZoomToggle'); if (wt) wt.classList.toggle('active', config.wheelZoom); const it = $('izImageInfoToggle'); if (it) it.classList.toggle('active', config.showImageInfo); const vt = $('izVideoHoverToggle'); if (vt) vt.classList.toggle('active', config.videoHoverPreview); const gp = $('izGalleryPreloadToggle'); if (gp) gp.classList.toggle('active', config.galleryPreload !== false); const bl = $('izBiliToggle'); if (bl) bl.classList.toggle('active', bilibiliVolumeModule.isEnabled); // 数值参数:滑块与数字框一起校正,并重绘填充色与「已改」标记 COMMON_PARAM_DEFS.concat(FIXED_PARAM_DEFS).forEach(p => syncParamControls(p.key, config[p.key])); syncPlaces(); // 显示位置卡片选中态 syncTransitions(); // 出现方式卡片选中态 syncSizeStep(); // 预览尺寸档位选中态 // ★ 历史列表条目也要一起刷新:悬停预览会持续写入历史,而 renderHistory 原先只在 // 面板「构建时 / 折叠展开时 / 删除时」调用 → 重开面板看到的是陈旧列表 // (角标 iznHistTag 却由 iznSync 更新过,于是出现「角标 4 条、列表 0 条」的错位)。 renderHistory(); iznSync(); // 总开关 aria-checked + 规则/规则包/历史条数角标 } // ===== 键位:可视化改绑 ===== const keymapGrid = $('izKeymapGrid'); function keyCapText(action) { const list = (config.keymap && config.keymap[action]) || []; return list.length ? list.map(displayKeyName).join(' / ') : '未设置'; } function renderKeymap() { if (!keymapGrid) return; keymapGrid.innerHTML = KEYMAP_ACTION_DEFS.map(function (d) { return '
' + escapeHtml(d.name) + '' + escapeHtml(d.hint) + '
'; }).join(''); } function wireSwitches() { // 快捷开关 / 折叠区:键盘可操作 + 无障碍状态同步 overlay.querySelectorAll('.iz-switch-card').forEach(wrap => { const tg = wrap.querySelector('.iz-toggle'); if (!tg) return; const syncSw = () => wrap.setAttribute('aria-checked', tg.classList.contains('active') ? 'true' : 'false'); new MutationObserver(syncSw).observe(tg, { attributes: true, attributeFilter: ['class'] }); syncSw(); wrap.addEventListener('keydown', (e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); wrap.click(); } }); }); } // 规则包状态原先只在「构建面板」与「手动更新/回滚」后渲染;自动更新完成后若面板正开着, // 就会一直显示打开那一刻的过期快照(如仍写着「本站无专属规则」)→ 留一个钩子让自动路径也能刷新。 let refreshPackStatusIfOpen = function () { }; function wireRulePack() { // 规则包:原独立折叠区块已并入「图片规则」区的 ② 云端规则包,元素 id 保持不变,状态渲染与按钮绑定无需改动。 function renderPackStatus() { const en = isEnUI(); const badge = $('izPackBadge'), st = $('izPackStatus'), note = $('izPackNote'); if (!badge || !st || !note) return; const meta = rulePackState.meta || {}; const cur = rulePackState.current; if (cur && cur.version) { badge.textContent = 'v' + cur.version; st.textContent = en ? ('Rule pack v' + cur.version + (meta.at ? ' (' + String(meta.at).slice(0, 10) + ')' : '') + ' loaded') : ('已加载规则包 v' + cur.version + (meta.at ? '(' + String(meta.at).slice(0, 10) + ')' : '')); } else { badge.textContent = en ? 'built-in' : '内置'; st.textContent = en ? 'Using built-in rules' : '当前使用内置规则'; } const hits = ((cur && cur.domains) || []).filter(function (d) { return packDomainMatches(currentDomain, d.domain); }); const extra = hits.length ? ((en ? ' Matched here: ' : ' 本站命中:') + hits.map(function (d) { return d.label || d.domain; }).join(en ? ', ' : '、')) : (en ? ' No site-specific rules' : ' 本站无专属规则'); // 签名校验状态(供应链加固):verified 只在「验签+哈希全过」时写入 meta let sig = ''; if (meta.verified) sig = en ? ' 🔒 Signature verified' : ' 🔒 签名校验通过'; else if (meta.lastError && /^sig-|^hash-/.test(meta.lastError)) sig = (en ? ' ⚠ Signature check failed (' : ' ⚠ 签名校验未通过(') + meta.lastError + (en ? ')' : ')'); const err = meta.lastError ? ((en ? ' Last update failed: ' : ' 上次更新失败:') + meta.lastError) : ''; note.textContent = (en ? 'The rule pack only supplies regex → replacement data; no code is executed.' : '规则包只提供「正则 → 替换」的数据,不执行任何代码。') + sig + extra + err; } refreshPackStatusIfOpen = renderPackStatus; renderPackStatus(); const packAutoWrap = $('izPackAutoWrap'); if (packAutoWrap) { packAutoWrap.addEventListener('click', () => { config.rulePackAuto = !config.rulePackAuto; $('izPackAutoToggle').classList.toggle('active', config.rulePackAuto); packAutoWrap.setAttribute('aria-checked', config.rulePackAuto ? 'true' : 'false'); saveConfig(); }); } const packUpdateBtn = $('izPackUpdateBtn'); if (packUpdateBtn) { packUpdateBtn.addEventListener('click', () => { packUpdateBtn.disabled = true; packUpdateBtn.textContent = '更新中…'; updateRulePack(true).then(function (r) { packUpdateBtn.disabled = false; packUpdateBtn.textContent = '↻ 立即更新'; renderPackStatus(); if (r && r.ok) showToast(r.unchanged ? '已是最新规则包 v' + r.version : '规则包已更新到 v' + r.version + ' 🎉'); else if (r && r.reason === 'busy') showToast('规则包正在更新中,请稍候再试'); else showToast('更新失败:' + ((r && r.reason) || '未知') + '(继续使用现有规则)'); }); }); } const packRollbackBtn = $('izPackRollbackBtn'); if (packRollbackBtn) { packRollbackBtn.addEventListener('click', () => { const r = rollbackRulePack(); renderPackStatus(); showToast(r.ok ? '已回滚到 v' + r.version : '没有可回滚的版本'); }); } } function wireUrlRules() { // ===== 图片地址规则(极简版:一键找大图;正则收进「高级」)===== const urHeader = $('izUrlRuleHeader'), urBody = $('izUrlRuleBody'); if (urHeader && urBody) { wireCollapseA11y(urHeader, urBody); urHeader.addEventListener('click', () => { const isOpen = urBody.classList.contains('open'); urBody.classList.toggle('open'); $('izUrlRuleArrow').classList.toggle('open'); $('izUrlRuleHint').innerHTML = isOpen ? '点开编辑' : '点击收起'; if (!isOpen) renderUrlRuleStatus(); // 展开时刷新「最近命中」等状态 }); } renderUrlRules(); renderUrlRuleStatus(); // 新布局默认展开规则区,打开面板即显示命中状态 renderUrlRuleStatus(); } function wireHistory() { const histHeader = $('izHistoryHeader'), histBody = $('izHistoryBody'); if (histHeader && histBody) { wireCollapseA11y(histHeader, histBody); histHeader.addEventListener('click', () => { const isOpen = histBody.classList.contains('open'); histBody.classList.toggle('open'); $('izHistoryArrow').classList.toggle('open'); $('izHistoryHint').innerHTML = isOpen ? '点开查看' : '点击收起'; if (!isOpen) renderHistory(); // 展开时刷新(悬停可能新增了记录) }); const histClear = $('izHistoryClearBtn'); if (histClear) histClear.addEventListener('click', () => { clearHistory(); renderHistory(); iznSync(); // ★ 角标(导航栏历史条数)也要跟着归零,否则列表空了角标还挂着旧数 showSaveToast('历史记录已清空'); }); const histView = $('izHistoryView'); if (histView) histView.addEventListener('click', (e) => { const btn = e.target.closest('button[data-val]'); if (!btn) return; const want = btn.dataset.val === 'gallery'; if (want === histGalleryOn()) return; globalPrefs.historyGallery = want; saveGlobalPrefs(); renderHistory(); }); const histList = $('izHistoryList'); if (histList) histList.addEventListener('click', (e) => { const item = e.target.closest('[data-hid]'); if (!item) return; const hid = item.getAttribute('data-hid'); const actEl = e.target.closest('[data-act]'); // 画廊视图里整格就是「回看」入口:点缩略图本身等同点「打开」 const act = actEl ? actEl.getAttribute('data-act') : (histGalleryOn() ? 'open' : ''); const entry = getHistory().find(x => x && x.id === hid); if (act === 'del') { removeHistoryItem(hid); renderHistory(); iznSync(); } else if (act === 'open' && entry && typeof GM_openInTab === 'function') { try { GM_openInTab(entry.u, { active: true }); } catch (err) { showSaveToast('打开失败'); } } else if (act === 'copy' && entry && typeof GM_setClipboard === 'function') { GM_setClipboard(entry.u, 'text'); showSaveToast('已复制图片地址'); } }); } renderHistory(); } function wireAutoFind() { // ★ 一键找大图:把所有已知变换在这个地址上各试一遍,真实加载后按大小给用户挑。 // 用户完全不需要懂正则——看到的缩略图就是最终效果。 let autoCands = []; function loadImgForTest(url) { return new Promise(function (res) { const t = setTimeout(function () { res(null); }, 8000); const im = new Image(); im.onload = function () { clearTimeout(t); res({ w: im.naturalWidth, h: im.naturalHeight }); }; im.onerror = function () { clearTimeout(t); res(null); }; im.src = url; }); } async function autoFindBig(url) { const out = $('izUrlAutoOut'); try { out.innerHTML = '
正在尝试各种方式…
'; const orig = await loadImgForTest(url); if (!orig) { out.innerHTML = '
这个地址打不开。请确认复制的是「图片地址」(右键图片 → 复制图片地址)。
'; return; } const cands = []; const seen = new Set(); activeHdRules.forEach(function (r) { (r.steps || []).forEach(function (s) { // ★ 兼容两种步长格式:内置 [regex, replace](2 元素)/ 规则包 [pattern, flags, replace](3 元素) const is3 = s.length > 2; const pat = s[0], fl = is3 ? (s[1] || '') : '', rep = is3 ? s[2] : s[1]; let re = (pat instanceof RegExp) ? pat : new RegExp(pat, fl); let c; try { c = url.replace(re, rep); } catch (e) { return; } if (c && c !== url && !seen.has(c)) { seen.add(c); cands.push({ rule: { label: r.name || '自动优化', phase: 'hd', pattern: pat, flags: fl, replace: rep }, url: c }); } }); }); if (!cands.length) { const dbg = []; activeHdRules.forEach(function (r) { let chg = false; (r.steps || []).forEach(function (s) { const is3 = s.length > 2; const pat = s[0], fl = is3 ? (s[1] || '') : '', rep = is3 ? s[2] : s[1]; try { const re = (pat instanceof RegExp) ? pat : new RegExp(pat, fl); const t = url.replace(re, rep); if (t !== url) chg = true; } catch (e) { chg = 'ERR'; } }); dbg.push((r.id || '?') + (chg === true ? '✓' : (chg === 'ERR' ? '✗err' : '·'))); }); out.innerHTML = '
暂时没有已知的优化方式适用于这个地址。' + '
(诊断:' + dbg.join(',') + ')
'; return; } const results = await Promise.all(cands.map(function (c) { return loadImgForTest(c.url).then(function (r) { return r ? { c: c, w: r.w, h: r.h } : null; }); })); // 按位置对应:加载成功的带尺寸,失败的标「未验证」(不能靠 slice 尾部——失败项散布在 results 里) const loaded = results.map(function (r, i) { return r ? { c: cands[i], w: r.w, h: r.h } : null; }).filter(Boolean); const unverified = results.map(function (r, i) { return r ? null : { c: cands[i], w: 0, h: 0 }; }).filter(Boolean); autoCands = loaded.concat(unverified); if (!autoCands.length) { out.innerHTML = '
这个地址没有可尝试的变换。
'; return; } autoCands.sort(function (a, b) { return (b.w * b.h) - (a.w * a.h); }); // 未验证(w=0)排最后 const rowHtml = function (r, i) { return '
' + '' + '
' + (r.w ? (r.w + ' × ' + r.h) : '未验证') + '
' + '
' + escapeHtml(r.c.url.slice(-72)) + '
' + '
'; }; const biggerIdx = [], smallerIdx = [], unverIdx = []; autoCands.forEach(function (r, i) { if (!r.w) unverIdx.push(i); else if (r.w * r.h > orig.w * orig.h) biggerIdx.push(i); else smallerIdx.push(i); }); let html = ''; if (biggerIdx.length) { html += '
找到 ' + biggerIdx.length + ' 个更大的版本(原来 ' + orig.w + '×' + orig.h + '),点一个就用:
' + biggerIdx.map(function (i) { return rowHtml(autoCands[i], i); }).join(''); } else { html += '
没有比原图(' + orig.w + '×' + orig.h + ')更大的版本。
'; } if (smallerIdx.length) { html += '
另有 ' + smallerIdx.length + ' 种方式,但没比原图大' + '
' + smallerIdx.map(function (i) { return rowHtml(autoCands[i], i); }).join('') + '
'; } if (unverIdx.length) { html += '
未验证(加载失败,可手动试)' + unverIdx.length + ' 个' + '
' + unverIdx.map(function (i) { return rowHtml(autoCands[i], i); }).join('') + '
'; } out.innerHTML = html; } catch (e) { out.innerHTML = '
出错了:' + escapeHtml(String(e && e.message || e)) + '
'; } } $('izUrlAutoBtn').addEventListener('click', function () { const v = $('izUrlAutoIn').value.trim(); if (!v) { showSaveToast('请先粘贴图片地址'); return; } autoFindBig(v); }); // ★ 点图选图:关面板 → 用户直接点网页图片 → 自动找大图(不用复制粘贴) function extractPickUrl(t) { let el = t, hops = 0; while (el && el !== document.body && hops < 4) { if (el.tagName === 'IMG') { const u = el.currentSrc || el.src || ''; if (u) return u; } try { const bg = getComputedStyle(el).backgroundImage; if (bg && bg !== 'none') { const m = bg.match(/url\((['"]?)(.+?)\1\)/); if (m && m[2] && m[2].indexOf('data:') !== 0) return m[2]; } } catch (e) { } el = el.parentElement; hops++; } return ''; } let pickBar = null, pickClick = null, pickKeydown = null, pickStyle = null; function closePickUi() { urlPickMode = false; document.documentElement.classList.remove('hv-picking'); if (pickClick) { window.removeEventListener('click', pickClick, true); pickClick = null; } if (pickKeydown) { window.removeEventListener('keydown', pickKeydown, true); pickKeydown = null; } if (pickBar) { pickBar.remove(); pickBar = null; } if (pickStyle) { pickStyle.remove(); pickStyle = null; } } function reopenPanelAfterPick() { const ov = document.getElementById('izModalOverlay'); if (!ov || ov.style.display !== 'flex') toggleConfigPanel(); const body = document.getElementById('izUrlRuleBody'); if (body && !body.classList.contains('open')) { const h = document.getElementById('izUrlRuleHeader'); if (h) h.click(); } setTimeout(function () { const b = document.getElementById('izUrlAutoIn'); if (b) b.scrollIntoView({ block: 'center' }); }, 80); } $('izUrlPickBtn').addEventListener('click', function () { if (urlPickMode) return; urlPickMode = true; try { zoomFSM.dispatch('DISMISS'); } catch (e) { } // 收起可能开着的预览 const ov = document.getElementById('izModalOverlay'); if (ov && ov.style.display === 'flex') ov.style.display = 'none'; // 面板让位,别挡图 pickStyle = document.createElement('style'); pickStyle.textContent = 'html.hv-picking, html.hv-picking *{cursor:crosshair!important}'; document.documentElement.appendChild(pickStyle); document.documentElement.classList.add('hv-picking'); pickBar = document.createElement('div'); pickBar.style.cssText = 'position:fixed;left:50%;top:18px;transform:translateX(-50%);z-index:2147483000;background:rgba(15,23,42,.92);color:#fff;padding:10px 20px;border-radius:12px;font-size:13px;font-family:Arial,sans-serif;box-shadow:0 8px 28px rgba(0,0,0,.35);pointer-events:none;white-space:nowrap;'; pickBar.textContent = '🖱 点击网页上想看大图的图片(Esc 取消)'; document.body.appendChild(pickBar); pickKeydown = function (e) { if (e.key !== 'Escape') return; e.stopImmediatePropagation(); closePickUi(); reopenPanelAfterPick(); showSaveToast('已取消选图'); }; window.addEventListener('keydown', pickKeydown, true); pickClick = function (e) { // 自有 UI 不算选图(面板此时已关,防误触 dock/预览层) if (e.target && e.target.closest && e.target.closest('#zoomDockZone, #izModalOverlay, #izIntroOverlay, #izUpdateNotice, #izHelpModal, .image-zoom-container')) return; const url = extractPickUrl(e.target); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); // 别点进链接 / 别触发页面行为 closePickUi(); if (url) { $('izUrlAutoIn').value = url; reopenPanelAfterPick(); autoFindBig(url); } else { reopenPanelAfterPick(); showSaveToast('刚才点的不是图片,再点「点图选图」选一张网页图片'); } }; window.addEventListener('click', pickClick, true); }); $('izUrlAutoOut').addEventListener('click', (e) => { const b = (e.target && e.target.closest) ? e.target.closest('[data-autoidx]') : null; if (!b) return; const cand = autoCands[Number(b.dataset.autoidx)]; if (!cand || !cand.c) return; const ruleSrc = cand.c.rule; const patStr = (ruleSrc.pattern instanceof RegExp) ? ruleSrc.pattern.source : String(ruleSrc.pattern || ''); config.userUrlRules = normalizeUserUrlRules((config.userUrlRules || []).concat([ Object.assign({ phase: 'hd', enabled: true }, ruleSrc, { id: 'u' + Date.now(), label: (cand.w ? ('大图 ' + cand.w + '×' + cand.h) : '自动找的大图规则'), enabled: true, pattern: patStr }, readRuleScope()) ])); commitUrlRules(); showSaveToast('已保存!本站这类图片以后自动换大图 🎉'); $('izUrlAutoOut').innerHTML = ''; $('izUrlAutoIn').value = ''; }); } function wireAdvancedRegex() { // 高级:手写正则(保留给会写的人) $('izUrlRuleSave').addEventListener('click', () => { const pattern = $('izUrlPattern').value.trim(); if (!pattern) { showSaveToast('正则 pattern 不能为空'); return; } const flags = $('izUrlFlags').value.trim().replace(/[^gimsuy]/g, ''); try { new RegExp(pattern, flags); } catch (e) { showSaveToast('正则不合法:' + e.message); return; } config.userUrlRules = normalizeUserUrlRules((config.userUrlRules || []).concat([ Object.assign({ id: 'u' + Date.now(), label: $('izUrlLabel').value.trim(), phase: segValue('izUrlPhase', 'hd') === 'clean' ? 'clean' : 'hd', pattern: pattern, flags: flags, replace: $('izUrlReplace').value, enabled: true }, readRuleScope()) ])); ['izUrlPattern', 'izUrlFlags', 'izUrlReplace', 'izUrlLabel'].forEach(function (id) { $(id).value = ''; }); commitUrlRules(); showSaveToast('规则已保存并立即生效'); }); $('izUrlRuleList').addEventListener('click', (e) => { const item = (e.target && e.target.closest) ? e.target.closest('.iz-urlrule-item') : null; if (!item) return; const id = item.dataset.id; if (e.target.classList.contains('iz-urlrule-enabled')) { config.userUrlRules = (config.userUrlRules || []).map(function (r) { return r.id === id ? Object.assign({}, r, { enabled: e.target.checked }) : r; }); commitUrlRules(); } else if (e.target.classList.contains('iz-urlrule-del')) { config.userUrlRules = (config.userUrlRules || []).filter(function (r) { return r.id !== id; }); commitUrlRules(); showSaveToast('已删除该规则'); } }); } function wireContribute() { // 贡献闭环:把当前站点的自定义规则导出为「可直接放进 rules/ 目录」的片段 function buildRuleSnippet() { // 只导出「在当前站确实生效」的规则,避免把别的站的规则当成本站提交 const rules = (config.userUrlRules || []).filter(function (r) { if (r.enabled === false) return false; if (r.scope === 'site' && r.domain && !packDomainMatches(currentDomain, r.domain)) return false; return true; }); if (!rules.length) return null; return JSON.stringify({ domain: currentDomain, label: currentDomain, note: '由用户在面板中导出;建议先在本站确认有效再提交', hd: rules.filter(function (r) { return r.phase !== 'clean'; }).map(function (r, i) { return { id: 'u' + (i + 1), name: r.label || '原图还原', loop: false, steps: [[r.pattern, r.flags, r.replace]] }; }), clean: rules.filter(function (r) { return r.phase === 'clean'; }).map(function (r, i) { return { id: 'c' + (i + 1), name: r.label || '地址清理', loop: false, steps: [[r.pattern, r.flags, r.replace]] }; }) }, null, 1); } $('izUrlRuleShare').addEventListener('click', () => { const snippet = buildRuleSnippet(); if (!snippet) { showSaveToast('还没有可导出的自定义规则'); return; } copyText(snippet).then(function (ok) { showSaveToast(ok ? '规则片段已复制,可粘贴到 rules/ 目录或提交 Issue' : '复制失败,请手动复制'); }); }); // 一键提交:打开 GitHub 新建 Issue 页面,标题与正文已自动填好(同时把片段复制到剪贴板兜底) $('izUrlRuleFeedback').addEventListener('click', () => { const snippet = buildRuleSnippet(); if (!snippet) { showSaveToast('先在本站加一条规则(用「帮我找大图」)再提交'); return; } try { copyText(snippet); } catch (e) { } const title = '[规则] ' + currentDomain + ' 大图还原'; const body = [ '### 站点', currentDomain, '', '### 规则片段(面板导出,已在本站验证)', '```json', snippet, '```', '', '### 环境', '- 悬景版本:' + SCRIPT_VERSION, '- 浏览器:' + navigator.userAgent, '', '> 由面板「一键提交到官方」生成;提交前请确认规则在本站确实有效。' ].join('\n'); const url = 'https://github.com/YDGG123/hover-image-zoom/issues/new' + '?title=' + encodeURIComponent(title) + '&body=' + encodeURIComponent(body); // 优先用 GM_openInTab(脚本沙箱里 window.open 常被拦截) let opened = false; try { if (typeof GM_openInTab === 'function') { GM_openInTab(url, { active: true, insert: true }); opened = true; } } catch (e) { opened = false; } if (!opened) { try { opened = !!window.open(url, '_blank'); } catch (e) { opened = false; } } showSaveToast(opened ? '已打开 GitHub 提交页(内容已填好,规则片段也已复制)' : '未能自动打开:规则片段已复制,请手动打开 GitHub Issue'); }); } function wireTipBubble() { // 参数说明气泡 const oldTip = document.getElementById('izTipBubble'); if (oldTip) oldTip.remove(); const tipBubble = document.createElement('div'); tipBubble.id = 'izTipBubble'; document.body.appendChild(tipBubble); const showTip = (icon) => { tipBubble.textContent = icon.dataset.tip; const r = icon.getBoundingClientRect(); tipBubble.style.opacity = '0'; tipBubble.style.display = 'block'; const bw = tipBubble.offsetWidth, bh = tipBubble.offsetHeight; let top = r.top - bh - 10; if (top < 8) top = r.bottom + 10; let left = r.left + r.width / 2 - bw / 2; left = Math.max(8, Math.min(left, window.innerWidth - bw - 8)); tipBubble.style.top = top + 'px'; tipBubble.style.left = left + 'px'; tipBubble.style.opacity = '1'; }; const hideTip = () => { tipBubble.style.opacity = '0'; }; overlay.addEventListener('mouseover', (e) => { const icon = e.target.closest('.iz-tip-icon'); if (icon) showTip(icon); }); overlay.addEventListener('mouseout', (e) => { if (e.target.closest('.iz-tip-icon')) hideTip(); }); } function wireNumericParams() { function commitParam(key, val) { const def = COMMON_PARAM_DEFS.concat(FIXED_PARAM_DEFS).find(p => p.key === key); if (isNaN(val)) val = defaultConfig[key]; // 把值对齐到「滑块步进」网格,保证数字框与滑块取值一致:步进取 min(定义步进, 1), // 整数参数可用任意整数、小数参数保持 0.1 精度。 if (def && def.step) { const step = Math.min(Number(def.step) || 1, 1); const base = (typeof def.min === 'number') ? def.min : 0; val = Math.round((val - base) / step) * step + base; val = Math.round(val * 1000) / 1000; } if (CONFIG_LIMITS[key]) { const lim = CONFIG_LIMITS[key]; val = Math.max(lim[0], Math.min(lim[1], val)); } config[key] = val; saveConfig(); syncParamControls(key, val); notifyConfigSaved(key, val, def ? def.label : key); } overlay.querySelectorAll('.iz-slider').forEach(sl => { const key = sl.dataset.param; paintSlider(sl); sl.addEventListener('input', () => { paintSlider(sl); config[key] = parseFloat(sl.value); syncParamControls(key, sl.value, sl); }); sl.addEventListener('change', () => { commitParam(key, parseFloat(sl.value)); const num = overlay.querySelector('.iz-param-num[data-param="' + key + '"]'); if (num) { num.classList.remove('pop'); void num.offsetWidth; num.classList.add('pop'); } if (key === 'maxWidth' || key === 'maxHeight') syncSizeStep(); }); }); overlay.querySelectorAll('.iz-param-num').forEach(num => { const key = num.dataset.param; const apply = () => { commitParam(key, parseFloat(num.value)); if (key === 'maxWidth' || key === 'maxHeight') syncSizeStep(); }; num.addEventListener('change', apply); num.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); num.blur(); } }); }); } function wireSegments() { // ===== 分段控件(替代下拉框)===== function syncSeg(key, val) { const seg = overlay.querySelector('.iz-seg[data-seg="' + key + '"]'); if (!seg) return; seg.querySelectorAll('button').forEach(b => b.classList.toggle('on', String(b.dataset.val) === String(val))); } const zoomModesBox = $('iznZoomModes'); if (zoomModesBox) { zoomModesBox.addEventListener('click', (e) => { const btn = (e.target && e.target.closest) ? e.target.closest('.izn-mode') : null; if (!btn) return; config.zoomMode = btn.dataset.mode === 'fixed' ? 'fixed' : 'adaptive'; saveConfig(); syncZoomModes(); updateDetailState(); showSaveToast('已切到 ' + (config.zoomMode === 'fixed' ? '固定倍数' : '智能自适应') + ' 模式'); }); } const sizeStepsBox = $('iznSizeSteps'); if (sizeStepsBox) { sizeStepsBox.addEventListener('click', (e) => { const btn = (e.target && e.target.closest) ? e.target.closest('.izn-size') : null; if (!btn) return; const s = SIZE_STEPS[parseInt(btn.dataset.val, 10)]; if (!s) return; config.maxWidth = s.w; config.maxHeight = s.h; saveConfig(); syncParamControls('maxWidth', s.w); syncParamControls('maxHeight', s.h); syncSizeStep(); showSaveToast('预览尺寸:' + s.name); }); } // 规则区两个分段控件(urlScope / urlPhase)只作为「下一条规则」的默认值, // 由 readRuleScope 与保存时读取,无需落盘 —— 这里只负责切换选中态。 overlay.querySelectorAll('.iz-seg[data-seg]').forEach(seg => { seg.addEventListener('click', (e) => { const btn = (e.target && e.target.closest) ? e.target.closest('button[data-val]') : null; if (!btn || !seg.contains(btn)) return; seg.querySelectorAll('button').forEach(b => b.classList.toggle('on', b === btn)); }); }); $('izHpToggleBtn').addEventListener('click', (e) => { e.stopPropagation(); toggleHomepageDisabled(); }); syncPlaces(); overlay.__iznSyncPlaces = syncPlaces; overlay.querySelectorAll('#iznPlaces .izn-place').forEach(p => p.addEventListener('click', () => { config.previewPlacement = p.dataset.place; saveConfig(); syncPlaces(); const names = { center: '屏幕居中', around: '原图周围(不遮挡)' }; showSaveToast(`显示位置:${names[config.previewPlacement]}(下次预览生效)`); })); syncTransitions(); overlay.__iznSyncTransitions = syncTransitions; overlay.querySelectorAll('#iznTransitions .izn-place').forEach(p => p.addEventListener('click', () => { config.previewTransition = p.dataset.fx; saveConfig(); syncTransitions(); const names = { dock: '从原图弹出', spotlight: '从原图绽开', fade: '直接淡入' }; showSaveToast('出现方式:' + (names[config.previewTransition] || config.previewTransition) + '(下次预览生效)'); })); } function wireIznNav() { // ===== izn 导航 / 总览同步 / 总开关 ===== const iznContent = $('iznContent'); function iznGo(view) { overlay.querySelectorAll('.izn-nav-item').forEach(x => x.classList.toggle('on', x.dataset.view === view)); overlay.querySelectorAll('.izn-view').forEach(v => v.classList.toggle('on', v.dataset.view === view)); if (iznContent) iznContent.scrollTop = 0; } overlay.querySelectorAll('.izn-nav-item').forEach(x => x.addEventListener('click', () => iznGo(x.dataset.view))); overlay.querySelectorAll('[data-izn-go]').forEach(b => b.addEventListener('click', () => iznGo(b.dataset.iznGo))); overlay.__iznGo = iznGo; overlay.__iznSync = iznSync; overlay.__izSyncAll = syncPanelFromState; // 总开关:isEnabled(image_zoom_enabled_<域名>),等效控制球。 // 用 overlay 捕获阶段委托(点击目标无论被内部结构如何包裹都能命中)。 overlay.addEventListener('click', (e) => { const wrap = (e.target && e.target.closest) ? e.target.closest('#iznMasterWrap') : null; if (!wrap) return; e.stopPropagation(); isEnabled = !isEnabled; storageSet('image_zoom_enabled_' + currentDomain, isEnabled); wrap.setAttribute('aria-checked', String(isEnabled)); const tg = $('iznMasterToggle'); if (tg) tg.classList.toggle('active', isEnabled); try { updateButtonState(); } catch (err) { } // 同步 dock 球的颜色/状态点 showSaveToast(isEnabled ? '已启用本网站图片放大' : '已停用本网站图片放大'); if (!isEnabled) { try { zoomFSM.dispatch('RESET'); } catch (err) { } } }, true); $('izConflictWrap').addEventListener('click', (e) => { e.stopPropagation(); config.avoidClickConflict = !config.avoidClickConflict; conflictToggle.classList.toggle('active', config.avoidClickConflict); saveConfig(); showSaveToast(`避免与点击放大功能冲突 ${config.avoidClickConflict ? '已开启' : '已关闭'}`); }); $('izBlurDismissWrap').addEventListener('click', (e) => { e.stopPropagation(); config.blurDismiss = !config.blurDismiss; $('izBlurDismissToggle').classList.toggle('active', config.blurDismiss); saveConfig(); showSaveToast(`窗口失焦时收起放大图 ${config.blurDismiss ? '已开启' : '已关闭(切换应用时保留预览)'}`); }); $('izWheelZoomWrap').addEventListener('click', (e) => { e.stopPropagation(); config.wheelZoom = !config.wheelZoom; $('izWheelZoomToggle').classList.toggle('active', config.wheelZoom); saveConfig(); showSaveToast(`滚轮控制放大图缩放 ${config.wheelZoom ? '已开启' : '已关闭(恢复上下移动)'}`); }); $('izVideoHoverWrap').addEventListener('click', (e) => { e.stopPropagation(); config.videoHoverPreview = !config.videoHoverPreview; $('izVideoHoverToggle').classList.toggle('active', config.videoHoverPreview); saveConfig(); if (!config.videoHoverPreview) videoPreviewModule.hide(); showSaveToast(`视频悬停预览 ${config.videoHoverPreview ? '已开启' : '已关闭'}`); }); $('izGalleryPreloadWrap').addEventListener('click', (e) => { e.stopPropagation(); config.galleryPreload = !config.galleryPreload; $('izGalleryPreloadToggle').classList.toggle('active', config.galleryPreload); saveConfig(); showSaveToast(`图集邻图预加载 ${config.galleryPreload ? '已开启' : '已关闭'}`); }); $('izImageInfoWrap').addEventListener('click', (e) => { e.stopPropagation(); config.showImageInfo = !config.showImageInfo; $('izImageInfoToggle').classList.toggle('active', config.showImageInfo); saveConfig(); // 关闭时若正有放大图在显示,顺手把已渲染的浮层摘掉 if (!config.showImageInfo) { const live = document.querySelector('#izImageInfo'); if (live) live.remove(); const liveBar = document.querySelector('.hv-capbar'); if (liveBar) liveBar.remove(); } showSaveToast(`图片信息栏 ${config.showImageInfo ? '已开启' : '已关闭'}`); }); } function wireKeymap() { // 键位区:与其它折叠区完全一致的交互 const keymapHeader = $('izKeymapHeader'); const keymapBody = $('izKeymapBody'); const keymapArrow = $('izKeymapArrow'); const keymapHint = $('izKeymapHint'); if (keymapHeader && keymapBody) { wireCollapseA11y(keymapHeader, keymapBody); keymapHeader.addEventListener('click', () => { const isOpen = keymapBody.classList.contains('open'); keymapBody.classList.toggle('open'); keymapArrow.classList.toggle('open'); keymapHint.innerHTML = isOpen ? '点右侧按键即可改绑' : '点击收起'; }); } $('iznToFixedBtn').addEventListener('click', () => { config.zoomMode = 'fixed'; saveConfig(); syncZoomModes(); updateDetailState(); showSaveToast('已切到固定倍数模式'); }); $('izBiliWrap').addEventListener('click', (e) => { e.stopPropagation(); const newState = !bilibiliVolumeModule.isEnabled; bilibiliVolumeModule.setEnabled(newState); biliToggle.classList.toggle('active', newState); showSaveToast(`B站播放器辅助 ${newState ? '已启用' : '已禁用'}`); }); let keyRecording = null; config.keymap = normalizeKeymap(config.keymap); // 兜底,避免旧配置缺字段 function stopKeyRecord() { if (!keyRecording) return; window.removeEventListener('keydown', onKeyRecord, true); keyRecording = null; } // 录制:捕获阶段拦截,避免被面板自身的 Esc/其它快捷键吃掉 function onKeyRecord(e) { if (!keyRecording) return; e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') { stopKeyRecord(); renderKeymap(); return; } if (['Control', 'Shift', 'Alt', 'Meta', 'CapsLock', 'Dead', 'Unidentified'].indexOf(e.key) >= 0) return; const k = e.key; // 一个键只归一个动作:先从其它动作里摘掉,避免同一个键绑两处 // (若该键原本绑在别的动作上,提示去向,用户不会疑惑「原来的键怎么失效了」) // 本次动作的旧键先留存:让出者优先「接手旧键」(换键语义),其次才回退默认键。 const oldKeysOfMover = (config.keymap[keyRecording] || []).slice(); const stolen = []; Object.keys(config.keymap).forEach(function (a) { if (a !== keyRecording) { const before = (config.keymap[a] || []).length; config.keymap[a] = (config.keymap[a] || []).filter(function (x) { return x !== k; }); if (config.keymap[a].length < before) stolen.push(a); } }); config.keymap[keyRecording] = [k]; // 让出后若原动作变空(「未设置」): // ① 优先接手本次动作换下来的旧键(如 resetZoom 抢走 s → saveImage 得到 resetZoom 原来的 0); // ② 否则回退到它的默认键(前提:该默认键未被任何动作占用)。 const usedKeys = new Set(); Object.keys(config.keymap).forEach(function (a) { (config.keymap[a] || []).forEach(function (x) { usedKeys.add(x); }); }); const restored = []; stolen.forEach(function (a) { if ((config.keymap[a] || []).length) return; // ① 换键:从本次动作的旧键里取一个仍空闲的 const sw = oldKeysOfMover.filter(function (x) { return x !== k && !usedKeys.has(x); }); if (sw.length) { config.keymap[a] = [sw[0]]; usedKeys.add(sw[0]); restored.push({ a: a, kind: 'swap', keys: [sw[0]] }); return; } // ② 退默认:默认键未被占用则用它 const free = (KEYMAP_DEFAULTS[a] || []).filter(function (x) { return !usedKeys.has(x); }); if (!free.length) return; config.keymap[a] = free.slice(); free.forEach(function (x) { usedKeys.add(x); }); restored.push({ a: a, kind: 'default', keys: free.slice() }); }); saveConfig(); saveGlobalKeymap(config.keymap); // ★ 键位全局:同步写入全局键 stopKeyRecord(); renderKeymap(); if (stolen.length) { const nameOf = function (a) { const d = KEYMAP_ACTION_DEFS.find(function (x) { return x.key === a; }); return d ? d.name : a; }; let msg = '「' + k + '」已从「' + nameOf(stolen[0]) + '」移到当前动作'; const r0 = restored[0]; if (r0 && r0.kind === 'swap') { msg += ';「' + nameOf(r0.a) + '」自动接手了原键 ' + r0.keys.map(displayKeyName).join(' / '); } else if (r0) { msg += ';「' + nameOf(r0.a) + '」已恢复默认键 ' + r0.keys.map(displayKeyName).join(' / '); } else { // 换键与默认键都不可用 → 无法自动回退;明确告知「去哪恢复」,避免用户不知原动作为何失效 const lost = stolen.filter(function (a) { return (config.keymap[a] || []).length === 0; }); if (lost.length) { msg += ';「' + nameOf(lost[0]) + '」已失去快捷键,点它的键帽可重新设置'; } } showSaveToast(msg); } } if (keymapGrid) { renderKeymap(); keymapGrid.addEventListener('click', function (e) { const btn = (e.target && e.target.closest) ? e.target.closest('.iz-key-cap') : null; if (!btn) return; stopKeyRecord(); keyRecording = btn.dataset.km; btn.classList.add('recording'); btn.textContent = '按下按键…'; window.addEventListener('keydown', onKeyRecord, true); }); // 恢复默认键位(只动键表,不动其它设置) const kmReset = $('izKeymapResetBtn'); if (kmReset) kmReset.addEventListener('click', function () { if (!confirm('把所有按键恢复为默认键位?')) return; config.keymap = normalizeKeymap(null); saveConfig(); saveGlobalKeymap(config.keymap); // ★ 键位全局:默认键位也要落全局键 stopKeyRecord(); renderKeymap(); showSaveToast('已恢复默认键位'); }); } } // 诊断信息:把排障要看的环境事实汇成一段纯文本,用户复制粘贴即可,省去来回追问。 function buildDiagnosticsText() { const L = (zh, en) => (isEnUI() ? en : zh); const pack = rulePackState.current || null; const meta = rulePackState.meta || {}; const lines = []; lines.push('===== HoverVista ' + L('诊断信息', 'diagnostics') + ' ====='); lines.push(L('脚本版本', 'Version') + ': ' + SCRIPT_VERSION + (HV_DEBUG ? ' (debug)' : '')); lines.push(L('界面语言', 'UI language') + ': ' + getLang() + (globalPrefs.lang === 'zh' || globalPrefs.lang === 'en' ? ' (' + globalPrefs.lang + ')' : ' (auto)')); lines.push(L('浏览器', 'Browser') + ': ' + (navigator.userAgent || '-')); lines.push(L('当前网站', 'Site') + ': ' + currentDomain); lines.push(L('页面地址', 'Page') + ': ' + (function () { try { return location.origin + location.pathname; } catch (e) { return '-'; } })()); lines.push(L('图片放大', 'Zoom enabled') + ': ' + (isEnabled ? L('已启用', 'yes') : L('已禁用', 'no'))); lines.push(L('主页策略', 'Homepage') + ': ' + (isHomepageDisabled() ? L('主页不启用', 'excluded') : L('主页正常', 'normal'))); lines.push(L('放大模式', 'Zoom mode') + ': ' + config.zoomMode); lines.push(L('预览尺寸', 'Preview size') + ': ' + config.maxWidth + ' × ' + config.maxHeight); lines.push(L('显示位置', 'Placement') + ': ' + config.previewPlacement); lines.push(L('出现方式', 'Transition') + ': ' + config.previewTransition); lines.push(L('规则包', 'Rule pack') + ': ' + (pack ? ('v' + (pack.version || '?')) : L('未加载', 'not loaded')) + (pack ? ' / ' + (meta.verified ? L('签名校验通过', 'signature ok') : L('未验证', 'unverified')) : '') + (meta.lastError ? ' / ' + L('上次失败', 'last error') + ': ' + meta.lastError : '')); lines.push(L('本站规则', 'Site rules') + ': ' + L('自定义 ', 'custom ') + (config.userUrlRules || []).length + L(' 条 / 规则包', ' / pack') + ': ' + ((pack && pack.domains && pack.domains.indexOf(currentDomain) >= 0) ? 1 : 0)); lines.push(L('键位', 'Keymap') + ': ' + (function () { try { const km = config.keymap || {}; let n = 0; Object.keys(KEYMAP_DEFAULTS).forEach(function (k) { if ((km[k] || []).join(',') !== KEYMAP_DEFAULTS[k].join(',')) n++; }); return n ? L('已自定义 ' + n + ' 项', n + ' customized') : L('默认', 'default'); } catch (e) { return '-'; } })()); lines.push(L('历史记录', 'History') + ': ' + getHistory().length + L(' 条', ' items')); lines.push(L('生成时间', 'Generated at') + ': ' + new Date().toISOString()); return lines.join('\n'); } function wireRestoreAndFeedback() { $('izResetBtn').addEventListener('click', () => { if (!confirm('确定恢复当前网站的默认设置吗?\n其它网站不受影响;全局键位也会保留。')) return; // ★ 键位表是全局配置(跨网站共享),不随「恢复本站默认」一起重置 —— // 否则用户在某站误点一次重置,所有网站的自定义键位都会丢。 const keepKeymap = normalizeKeymap(config.keymap); config = { ...defaultConfig }; config.keymap = keepKeymap; saveConfig(); // 总开关(isEnabled / image_zoom_enabled_<域名>)独立持久化、不在 defaultConfig 里: // 恢复默认时须四处一起同步(存储 / 内存变量 / 面板开关 / dock 球),漏一处就状态不一致。 isEnabled = true; storageSet('image_zoom_enabled_' + currentDomain, true); try { updateButtonState(); } catch (e) { } // 同步 dock 球的颜色/状态点 syncPanelFromState(); // 面板全部控件(含总开关/出现方式/视频悬停/显示位置) updateDetailState(); renderKeymap(); // 键位区跟随(全局键位未被改动,这里只是重绘) showToast('已恢复本站默认设置'); }); $('izExportBtn').addEventListener('click', (e) => { e.stopPropagation(); exportConfigToFile(); }); const izImportInput = $('izImportInput'); $('izImportBtn').addEventListener('click', (e) => { e.stopPropagation(); izImportInput.click(); }); izImportInput.addEventListener('click', (e) => e.stopPropagation()); izImportInput.addEventListener('change', (e) => { const file = e.target.files && e.target.files[0]; importConfigFromFile(file, (res) => { // 面板是照打开时的 config 渲染的,逐项同步容易漏;整块重建最稳, // 「站点规则自定义」区也会按导入后的规则重新渲染。 const msg = '已导入 ' + res.ok + ' 项配置' + (res.skipped ? ',跳过 ' + res.skipped + ' 项' : '') + ' ✅'; closePanel(); // 提示必须等面板重建完成后再显示:showToast 在面板开着时会挂进 #izConfigPanel,而这里紧接着 remove 整个 overlay → 提示会随之消失。 setTimeout(() => { panelAbort.abort(); overlay.remove(); toggleConfigPanel(); showToast(msg); }, 320); }); e.target.value = ''; // 清空以便连续导入同一个文件 }); function closePanel() { overlay.classList.add('anim-out'); setTimeout(() => { overlay.style.display = 'none'; overlay.classList.remove('anim-out'); }, 300); } $('izCloseBtn').addEventListener('click', closePanel); $('izHelpBtn').addEventListener('click', (e) => { e.stopPropagation(); showHelpModal('usage'); }); $('izChangelogBtn').addEventListener('click', (e) => { e.stopPropagation(); showHelpModal('changelog'); }); // 复制诊断信息:环境和状态一次给全,报问题时粘贴即可 $('izDiagBtn').addEventListener('click', (e) => { e.stopPropagation(); copyText(buildDiagnosticsText()).then(function (ok) { showSaveToast(ok ? (isEnUI() ? 'Diagnostics copied ✅' : '诊断信息已复制,粘贴到反馈里即可 ✅') : (isEnUI() ? 'Copy failed, please copy manually' : '复制失败,请手动复制')); }); }); // 界面语言:全局偏好,切换后整块重建面板(与导入配置同一路径,最稳) const langSeg = $('izLangSeg'); if (langSeg) { const cur = (globalPrefs.lang === 'zh' || globalPrefs.lang === 'en') ? globalPrefs.lang : 'auto'; langSeg.querySelectorAll('button').forEach(function (b) { b.classList.toggle('on', b.dataset.val === cur); }); langSeg.addEventListener('click', function (e) { const btn = e.target.closest('button[data-val]'); if (!btn || btn.dataset.val === cur) return; globalPrefs.lang = btn.dataset.val; saveGlobalPrefs(); const msg = getLang() === 'en' ? 'Language: English ✅' : '界面语言已切换 ✅'; // 固定浮层(控制球/介绍/说明弹窗)逐个还原后再按新语言重新翻译;面板走整块重建 document.querySelectorAll(I18N_ROOTS).forEach(function (el) { restoreI18nDeep(el); }); if (isEnUI()) applyI18nAll(); closePanel(); setTimeout(() => { panelAbort.abort(); overlay.remove(); toggleConfigPanel(); showToast(msg); }, 320); }); } // 反馈问题:自动附小尺寸截图(若有预览过图片)+ 预填环境信息的 GitHub Issue $('izFeedbackBtn').addEventListener('click', (e) => { e.stopPropagation(); // 1) 截图落盘(本地,不上传任何服务器;GitHub 提交时用户自行拖入) let shotName = ''; if (lastPreviewShotData) { shotName = 'hv-feedback-screenshot.png'; try { if (typeof GM_download === 'function') { GM_download({ url: lastPreviewShotData, name: shotName, saveAs: false, onerror: function () { } }); } else { downloadViaAnchor(lastPreviewShotData, shotName); } } catch (err) { shotName = ''; } } // 2) 预填 Issue:环境信息 + 截图指引 let ver = '', handler = ''; try { if (typeof GM_info !== 'undefined') { ver = GM_info.script && GM_info.script.version; handler = (GM_info.scriptHandler || '') + (GM_info.version ? ' ' + GM_info.version : ''); } } catch (err) { } const body = [ '### 问题描述', '', '(请在这里描述遇到的问题)', '', '### 复现步骤', '', '1. ', '2. ', '', '### 环境信息', '- 脚本版本: ' + (ver || '未知'), '- 脚本管理器: ' + (handler || '未知'), '- 出问题的页面: ' + location.href, '- User-Agent: ' + navigator.userAgent, '', '### 附件', shotName ? '已自动保存截图 `' + shotName + '`(在浏览器下载目录),请把该图片拖进上面的编辑框一并提交。' : '未能自动截图(最近没有预览过图片或图片受跨域保护),可自行截图后拖进编辑框。' ].join('\n'); const url = 'https://github.com/YDGG123/hover-image-zoom/issues/new' + '?title=' + encodeURIComponent('[反馈] ') + '&body=' + encodeURIComponent(body); try { GM_openInTab(url, { active: true }); } catch (err) { window.open(url, '_blank'); } showSaveToast(shotName ? '已保存截图并打开反馈页' : '已打开反馈页'); }); $('izSaveBtn').addEventListener('click', () => { showSaveToast('设置已保存'); setTimeout(closePanel, 350); }); overlay.addEventListener('click', (e) => { if (e.target === overlay) closePanel(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && overlay.style.display === 'flex') closePanel(); }, { signal: panelAbort.signal }); } // ---- 按原顺序接线 ---- wireSwitches(); wireRulePack(); wireUrlRules(); wireHistory(); wireAutoFind(); wireAdvancedRegex(); wireContribute(); wireTipBubble(); wireNumericParams(); wireSegments(); wireIznNav(); wireKeymap(); wireRestoreAndFeedback(); refreshPanelHomepageSection(); updateDetailState(); return overlay; } function toggleConfigPanel() { let overlay = document.getElementById('izModalOverlay'); if (!overlay) overlay = createConfigPanel(); if (overlay.style.display === 'flex') { overlay.style.display = 'none'; return; } // 每次打开面板都把「总览」与「全部控件」校正为真实状态(统一入口见 syncPanelFromState)。 if (overlay.__iznGo) overlay.__iznGo('overview'); const syncAll = () => { if (overlay.__izSyncAll) overlay.__izSyncAll(); else if (overlay.__iznSync) overlay.__iznSync(); }; syncAll(); setTimeout(syncAll, 80); refreshPanelHomepageSection(); overlay.classList.add('anim-in'); overlay.style.display = 'flex'; setTimeout(() => overlay.classList.remove('anim-in'), 400); } // 12. 主初始化 // 颜色标记:🟢 性能/结构优化区 · 🟡 兼容性相关区(改动需重点测试)· 🔴 hover 放大核心链路(避免直接重构)。 function mainInit() { loadConfig(); loadState(); injectStyles(); // ★ 调试钩子:仅在 URL 带 ?hvdebug=1 时暴露,用于端到端验证(如防盗链抓取),不影响正常使用 try { if (/[?&]hvdebug=1/.test(location.search)) { // 用户脚本运行在沙箱里,挂到沙箱 window 上外部读不到 → 优先挂 unsafeWindow const dbgWin = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window; dbgWin.__hvDebug = { gmFetchBlobUrl, upgradeImgUrl, buildHdCandidates, sizeHintOf, probeCandidates, pickBestCandidate, buildSizeVariants, collectElementSources, pickLargestSrcset, absUrl, SIZE_FAMILIES, LAZY_SRC_ATTRS, isStripLikeImg, // 细长条幅过滤(供端到端验证判据) rulePackUrls: () => rulePackUrlCandidates(RULE_PACK_URL), // 规则包取源顺序(主源 + 镜像) rulePackFetchCount: () => rulePackFetchCount, // 本轮页面内对规则包取源的请求次数(0 = 未联网) rulePackNeedsRefresh: () => rulePackNeedsRefresh(), // 启动时是否判定为「需要后台重校验」 rulePackMeta: () => rulePackState.meta, // 元信息(含 revalidatedAt / 逐域时间戳) rulePackDomains: () => ((rulePackState.current && rulePackState.current.domains) || []).map(function (d) { return d.domain; }), probeCacheSize: () => PROBE_CACHE.size, // 探测结果缓存条目数 preloadInfo: () => ({ count: preloadCount, urls: Array.from(preloadSeen), suppressed: preloadSuppressed(), enabled: config.galleryPreload !== false }), VERSION: SCRIPT_VERSION, fsm: zoomFSM, // 放大状态机(含 state getter) get config() { return config; }, // 当前网站配置(只读引用) snapshot() { // 运行时状态一键快照 return { version: SCRIPT_VERSION, state: zoomFSM.state, hasActiveZoom: zoomFSM.hasActiveZoom(), config: config }; } }; } } catch (e) { } // ★ UI 入口(dock 控制球/介绍面板/更新弹窗)只在顶层 frame 创建: // iframe 里再浮一个控制球既无必要,还会与顶层 UI 重叠、拦截鼠标事件。 if (IS_TOP) { createDockButton(); if (isEnabled) { setTimeout(showIntroPanel, 180); setTimeout(showUpdateNotice, 260); } } // ★ frame 被卸载(SPA 路由/iframe 移除)时释放仲裁心跳,别让别的 frame 等 TTL window.addEventListener('pagehide', () => arbiterStopRenew()); // ★ 全局 mousemove:坐标权威源(停稳裁决器/心跳/TIMER_FIRE 使用)。 // 直接写入不节流——handler 本身只有三次赋值,开销可忽略; // 停稳裁决器通过 debounce 自身控制频率,无需在此节流。 document.addEventListener('mousemove', (e) => { const x = e.clientX, y = e.clientY; const wasResumeBlocked = resumeBlockedUntilMouseMove; // ★ 关键修复:切回窗口时浏览器可能补发 mousemove,甚至坐标会发生微小变化。 // 在恢复保护期间,只接受真正带有物理指针位移的 mousemove。 // movementX/movementY 是浏览器提供的本次指针位移量;切回窗口产生的补发事件通常为 0。 const physicalMouseMove = Number(e.movementX || 0) !== 0 || Number(e.movementY || 0) !== 0; const movedAfterResume = !wasResumeBlocked || physicalMouseMove; // ★ 恢复保护期间,浏览器可能补发“假 mousemove”。 // 这类事件不仅不能解除保护,也不应污染 lastMouse 的权威坐标; // 否则切回窗口时旧坐标可能被补发事件覆盖,后续停稳裁决/心跳会误判。 if (!wasResumeBlocked || physicalMouseMove) { lastMouse.x = x; lastMouse.y = y; lastMouse.t = Date.now(); if (hoverWaitIndicator && hoverWaitIndicator.classList.contains('show')) { positionHoverWaitIndicator(x, y + 22); } pointerInWindow = true; browserWindowFocused = true; } // ★ 切换应用后的恢复保护:没有真实鼠标位移就一直保持阻塞。 // 防止 Alt+Tab / Finder / 文件管理器切回时自动补发事件再次触发放大。 if (movedAfterResume) { resumeBlockedUntilMouseMove = false; } }, { passive: true }); window.addEventListener('resize', debounce(() => { invalidateLightboxState(); if (isEnabled) zoomFSM.dispatch('DISMISS'); }, 250)); setupLightboxObserver(); if (isEnabled) initImages(); bilibiliVolumeModule.init(); setupGlobalHoverStream(); // ★ mouseover 流 + 停稳裁决器 双保险 videoPreviewModule.init(); // ★ 视频悬停预览:滚动/失焦/变形即关闭 setupHeartbeat(); // ★ 持续复核(PENDING/ACTIVE,带失败容忍,后台页暂停) setupBgRuleProxy(); setupAutoBackgroundHover(); // ★ 规则包:先用本地缓存立刻生效,再按需后台更新(失败静默降级,不影响基本功能) loadRulePackFromStorage(); // ★ 本地已有「本站命中」且未过期的规则包时不再联网(rulePackNeedsRefresh): // 云端规则不常变,每开一页都探一次既浪费流量,也会让首屏悬停落在「包还没就绪」的窗口里。 // 需要立刻取新的场景仍有手动「↻ 立即更新」。 if (config.rulePackAuto && rulePackNeedsRefresh()) { setTimeout(function () { updateRulePack(false).then(function () { refreshPackStatusIfOpen(); }); }, 1500); } keymapModule.init(); // ★ 键位系统:Esc 关闭 / +/- 缩放 / 0 原始尺寸 startObserver(); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mainInit); } else { mainInit(); } })();