NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name 鼠标悬停图片自动放大预览
// @namespace https://github.com/YDGG123
// @version 5.6.41
// @description 网页图片鼠标悬停自动放大工具:智能自适应、高清图后台升级、滚轮边界控制
// @author 益达哥哥
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect damp-woodpecker-4867.ydgg123.deno.net
// @run-at document-end
// @noframes
// @license MIT
// @homepageURL https://github.com/YDGG123/hover-image-zoom
// @supportURL https://github.com/YDGG123/hover-image-zoom/issues
// @downloadURL https://raw.githubusercontent.com/YDGG123/hover-image-zoom/main/hover-image-zoom.user.js
// ==/UserScript==
(function() {
'use strict';
const SCRIPT_VERSION = "5.6.41";
// ================
// ★ 存储适配层(当前仍使用 Tampermonkey GM_*,仅抽象接口,不改变行为)
// 后续迁移浏览器扩展时,只需要替换这里的实现。
// ================
const storage = {
get(key, defaultValue) {
return GM_getValue(key, defaultValue);
},
set(key, value) {
return GM_setValue(key, value);
}
};
const storageGet = (key, defaultValue) => storage.get(key, defaultValue);
const storageSet = (key, value) => storage.set(key, value);
function getDomain() {
try { return new URL(window.location.href).hostname; }
catch (e) { return window.location.hostname || 'unknown'; }
}
function debounce(fn, wait) {
let t;
return function(...a) { clearTimeout(t); t = setTimeout(() => fn.apply(this, a), wait); };
}
// 仅前沿节流(无尾随重放:每次执行携带自己那一刻的真实事件,无过期数据)
function throttleLeading(fn, wait) {
let lastExec = 0;
return function(...a) {
const now = Date.now();
if (now - lastExec >= wait) { lastExec = now; fn.apply(this, a); }
};
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function isHomepage() {
const p = window.location.pathname;
return p === '/' || p === '/index.html' || p === '/index.php' || p === '';
}
const FADE_MS = 300;
const HEARTBEAT_MS = 150;
// 坐标权威源:只由 mousemove 写入。
// 停稳裁决器与后续校验(心跳/TIMER_FIRE)使用
const lastMouse = { x: -1, y: -1, t: 0 };
// ★ 光标是否在浏览器窗口内:mouseout(relatedTarget=null) 置 false,mousemove 置 true。
// 原生窗口(文件管理器等)覆盖浏览器时,DOM 对遮挡毫无感知,
// elementFromPoint 和 lastMouse 都会给出"光标仍在图上"的假象,必须靠此标记纠偏
let pointerInWindow = true;
const currentDomain = getDomain();
// ★ 面板中央提示:配置面板打开时,所有提示统一显示在面板正中央
let panelToastEl = null, panelToastTimer = null;
function showPanelCenterToast(message) {
const overlay = document.getElementById('izModalOverlay');
const panel = overlay && overlay.querySelector('#izConfigPanel');
if (!panel || !overlay || overlay.style.display !== 'flex') return false;
panel.style.position = 'relative';
if (!panelToastEl || panelToastEl.parentNode !== panel) {
if (panelToastEl) panelToastEl.remove();
panelToastEl = document.createElement('div');
panelToastEl.id = 'izPanelToast';
panelToastEl.style.cssText = `position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) translateY(10px);
background:rgba(15,23,42,.88);color:#fff;padding:10px 22px;border-radius:14px;font-size:13px;font-weight:500;
font-family:Arial,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,.35);opacity:0;pointer-events:none;z-index:100006;
max-width:360px;text-align:center;white-space:nowrap;transition:opacity .25s ease,transform .25s ease;`;
panel.appendChild(panelToastEl);
}
panelToastEl.textContent = '✅ ' + message;
void panelToastEl.offsetWidth;
panelToastEl.style.opacity = '1';
panelToastEl.style.transform = 'translate(-50%,-50%) translateY(0)';
clearTimeout(panelToastTimer);
panelToastTimer = setTimeout(() => {
if (panelToastEl) {
panelToastEl.style.opacity = '0';
panelToastEl.style.transform = 'translate(-50%,-50%) translateY(10px)';
}
}, 1500);
return true;
}
function showToast(message, duration = 2000) {
if (showPanelCenterToast(message)) return; // 面板开着 → 显示在面板中央
let toast = document.getElementById('image-zoom-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'image-zoom-toast';
toast.style.cssText = `position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
background:rgba(0,0,0,.8);color:#fff;padding:12px 20px;border-radius:6px;z-index:1000000;
font-size:14px;font-family:Arial,sans-serif;opacity:0;transition:opacity .3s ease;pointer-events:none;`;
document.body.appendChild(toast);
}
toast.textContent = message;
toast.style.opacity = '1';
if (toast.timeoutId) clearTimeout(toast.timeoutId);
toast.timeoutId = setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => {
if (toast && toast.parentNode) toast.parentNode.removeChild(toast);
}, 300);
}, duration);
}
let saveToastTimeout = null, saveToastEl = null;
function showSaveToast(message) {
if (showPanelCenterToast(message)) return;
if (!saveToastEl || !saveToastEl.parentNode) {
saveToastEl = document.createElement('div');
saveToastEl.id = 'image-zoom-save-toast';
saveToastEl.style.cssText = `position:fixed;bottom:30px;left:50%;transform:translateX(-50%);
background:rgba(15,23,42,.88);color:#fff;padding:12px 24px;border-radius:16px;font-size:14px;
font-weight:500;font-family:Arial,sans-serif;box-shadow:0 8px 32px rgba(0,0,0,.25);
opacity:0;transition:all .3s cubic-bezier(.16,1,.3,1);pointer-events:none;z-index:1000001;
max-width:300px;text-align:center;`;
document.body.appendChild(saveToastEl);
}
saveToastEl.textContent = '✅ ' + message;
void saveToastEl.offsetWidth;
saveToastEl.style.opacity = '1';
saveToastEl.style.transform = 'translateX(-50%) translateY(0)';
clearTimeout(saveToastTimeout);
saveToastTimeout = setTimeout(() => {
if (saveToastEl) {
saveToastEl.style.opacity = '0';
saveToastEl.style.transform = 'translateX(-50%) translateY(16px)';
}
}, 1500);
}
// ================
// 1. 配置模块
// ================
// ================
// 1. 配置
// ================
const defaultConfig = {
delay: 500,
scale: 2,
maxWidth: 1200,
maxHeight: 980,
minScale: 1.4,
zoomZIndex: 9999,
scrollSpeed: 50,
smallImgThreshold: 280,
smallImgWidth: 500,
smallImgHeight: 430,
avoidClickConflict: true,
blurDismiss: true,
zoomMode: 'adaptive',
minOriginalSize: 51
};
const CONFIG_LIMITS = {
delay: [0, 2000],
scale: [1, 5],
maxWidth: [300, 3000],
maxHeight: [300, 3000],
minScale: [1, 3],
scrollSpeed: [5, 50],
smallImgThreshold: [100, 500],
smallImgWidth: [300, 1000],
smallImgHeight: [300, 1000],
minOriginalSize: [0, 500]
};
let config = { ...defaultConfig };
let isEnabled = true;
function validateConfig(cfg) {
const v = { ...cfg };
for (const [key, [min, max]] of Object.entries(CONFIG_LIMITS)) {
const val = parseFloat(v[key]);
v[key] = isNaN(val) ? defaultConfig[key] : Math.max(min, Math.min(max, val));
}
v.zoomMode = v.zoomMode === 'fixed' ? 'fixed' : 'adaptive';
v.blurDismiss = typeof v.blurDismiss === 'boolean' ? v.blurDismiss : true;
return v;
}
function loadConfig() {
const saved = storageGet(`image_zoom_config_${currentDomain}`);
if (saved) config = { ...defaultConfig, ...validateConfig(saved) };
}
function saveConfig() {
storageSet(`image_zoom_config_${currentDomain}`, config);
}
function loadState() {
isEnabled = storageGet(`image_zoom_enabled_${currentDomain}`) !== false;
if (isHomepageZoomDisabled()) isEnabled = false;
}
// 主页禁用状态(保留原有实现,仅从 main.js 移入配置模块)
function isHomepageDisabled() {
return storageGet(`image_zoom_homepage_disabled_${currentDomain}`, false);
}
function isHomepageZoomDisabled() {
return isHomepage() && isHomepageDisabled();
}
// ================
// 2. ★★★ 触发资格判定(轮播 BUG 的根治点)★★★
// ================
// 此刻是否样式可见:fade 型轮播的 opacity:0 待播帧在这里被拒
function isImgVisibleNow(img) {
if (!img || !img.isConnected) return false;
try {
const cs = window.getComputedStyle(img);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
if (parseFloat(cs.opacity) < 0.05) return false;
if (typeof img.checkVisibility === 'function' &&
!img.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false;
} catch (e) { }
return true;
}
function isImgClippedAway(img) {
if (!img || !img.isConnected) return true;
const ir = img.getBoundingClientRect();
if (ir.width < 2 || ir.height < 2) return true;
let node = img.parentElement;
while (node && node !== document.body) {
try {
const cs = getComputedStyle(node);
const hidden = (v) => v === 'hidden' || v === 'clip';
if (hidden(cs.overflowX) || hidden(cs.overflowY)) {
const nr = node.getBoundingClientRect();
if (ir.right <= nr.left || ir.left >= nr.right ||
ir.bottom <= nr.top || ir.top >= nr.bottom) {
return true;
}
}
} catch (e) { }
node = node.parentElement;
}
return false;
}
function inRect(x, y, r) {
return !!r && x >= r.left && x <= r.right && y >= r.top && y <= r.bottom;
}
// =================================================================
// ★ 覆盖物性质判定(只服务触发资格,不碰状态机)
// isMenuOverlay = 网站自己弹出的菜单/浮层 → 拦截
// isBlankCover = 无内容无样式的空白占位层 → 拦截
// 半透明哑遮罩(淘宝类盖图场景)→ 明确放行,不误杀
// =================================================================
// =============================
// 🟡 COMPATIBILITY ZONE
// 网站浮层/菜单兼容规则,谨慎修改
// =============================
function isMenuOverlay(el) {
if (!el || el === document.body || el === document.documentElement) return false;
// ★ el 自身是"空白透明层"时不是菜单:交给 isBlankCover 的规则
//(pointer 覆盖层/轮播点击区放行),否则会被有背景的祖先连坐误杀
if (isBlankCover(el)) return false;
try {
// 语义明确的 ARIA 浮层结构可以直接拦截。
if (el.closest('[role="menu"],[role="listbox"],[role="combobox"],[role="dialog"]')) return true;
// ★ 向上检查有限层级:很多老式网站的弹窗实际命中的是内部
// 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);
}
// 实时资格:连接 + 样式可见 + 未被裁出可视区 + 尺寸达标 + 鼠标在实时矩形内
// 祖先锚(遮罩盖图场景):面积 ≤ 图片 8 倍、光标距图片矩形 ≤ 120px
function canTriggerNow(img, x, y) {
if (!isImgVisibleNow(img)) return null;
const r = img.getBoundingClientRect();
if (r.width < config.minOriginalSize || r.height < config.minOriginalSize) return null;
if (inRect(x, y, r)) {
return isImgClippedAway(img) ? null : img;
}
const PROXIMITY = 120;
const imgArea = r.width * r.height;
const nearImg = (rect) => x >= rect.left - PROXIMITY && x <= rect.right + PROXIMITY &&
y >= rect.top - PROXIMITY && y <= rect.bottom + PROXIMITY;
let node = img.parentElement;
while (node && node !== document.body) {
if (node.closest && node.closest('.image-zoom-container')) break;
const nr = node.getBoundingClientRect();
const nArea = nr.width * nr.height;
if (inRect(x, y, nr) && nearImg(nr) && nArea <= imgArea * 8 &&
nr.width < window.innerWidth * 0.9 && nr.height < window.innerHeight * 0.9) {
return isImgClippedAway(img) ? null : node;
}
node = node.parentElement;
}
return null;
}
const COMMON_SELECTORS = [
'[onclick*="zoom"]', '[onclick*="lightbox"]', '[onclick*="gallery"]', '[onclick*="preview"]',
'[data-action*="zoom"]', '[data-lightbox]', '[data-gallery]', '[data-fancybox]',
'.zoomable', '.lightbox', '.gallery-item', '.fancybox', '.stretched-link'
];
function checkImageClickBehavior(img) {
for (const selector of COMMON_SELECTORS) if (img.matches(selector)) return true;
let parent = img.parentElement;
while (parent && parent !== document.body) {
const cls = (typeof parent.className === 'string' ? parent.className : (parent.getAttribute('class') || '')) || '';
const pid = parent.id || '';
if (/zoom|lightbox|gallery|fancybox|stretched-link/.test(cls) || /zoom|lightbox|gallery|fancybox/.test(pid)) return true;
parent = parent.parentElement;
}
return false;
}
function isValidImage(img) {
if (!img || img.tagName !== 'IMG' || !img.parentNode) return false;
if (img.getClientRects().length === 0) return false;
const style = window.getComputedStyle(img);
if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) < 0.05) return false;
const src = img.src || img.currentSrc;
if (!src || src.trim() === '' || src.startsWith('data:') || src.includes('placeholder')) return false;
if (style.backgroundImage && style.backgroundImage !== 'none') return false;
if (!img.complete || img.naturalWidth === 0) return false;
const rect = img.getBoundingClientRect();
return !(rect.width < 10 || rect.height < 10);
}
// 2. ★★★ 触发资格判定(已拆分至 image/eligibility.js)
// ================
// 3. 图片处理工具(已拆分至 image/url.js + image/crop.js)
// ================
// 5.6.23 image URL/background URL helpers: moved verbatim from 5.6.22.
function upgradeImgUrl(url) {
if (!url) return url;
return url.replace(/\/remote\/thumb\/\d+x\d+\//, '/');
}
function cropBlackBars(imgEl) {
try {
if (imgEl.dataset.zoomCropped) return;
const w = imgEl.naturalWidth, h = imgEl.naturalHeight;
if (!w || !h) return;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(imgEl, 0, 0);
let data;
try { data = ctx.getImageData(0, 0, w, h).data; } catch (e) { return; }
let transparentCount = 0;
const total = w * h, step = Math.max(1, Math.floor(total / 20000));
let sampled = 0;
for (let i = 0; i < total; i += step) {
if (data[i * 4 + 3] < 10) transparentCount++;
sampled++;
}
if (transparentCount / sampled > 0.05) return;
const threshold = 24;
const isContent = (i) => data[i + 3] > 10 &&
(data[i] > threshold || data[i + 1] > threshold || data[i + 2] > threshold);
const rowHas = (y0) => {
for (let x0 = 0; x0 < w; x0++) if (isContent((y0 * w + x0) * 4)) return true;
return false;
};
const colHas = (x0) => {
for (let y0 = 0; y0 < h; y0++) if (isContent((y0 * w + x0) * 4)) return true;
return false;
};
let top = 0;
while (top < h && !rowHas(top)) top++;
if (top === h) return;
let bottom = h - 1;
while (bottom > top && !rowHas(bottom)) bottom--;
let left = 0;
while (left < w && !colHas(left)) left++;
let right = w - 1;
while (right > left && !colHas(right)) right--;
top = Math.min(top, Math.floor(h * 0.25));
bottom = Math.max(bottom, h - 1 - Math.floor(h * 0.25));
left = Math.min(left, Math.floor(w * 0.25));
right = Math.max(right, w - 1 - Math.floor(w * 0.25));
const cw = right - left + 1, ch = bottom - top + 1;
if (cw >= w * 0.9 && ch >= h * 0.9) return;
if (cw < 20 || ch < 20) return;
const out = document.createElement('canvas');
out.width = cw;
out.height = ch;
out.getContext('2d').drawImage(imgEl, left, top, cw, ch, 0, 0, cw, ch);
imgEl.dataset.zoomCropped = '1';
out.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
if (imgEl.__zoomBlobUrl) URL.revokeObjectURL(imgEl.__zoomBlobUrl);
imgEl.__zoomBlobUrl = url;
imgEl.src = url;
// 容器尺寸保持不变(尊重当前模式的尺寸语义),
// 只按新宽高比在容器内做 contain 重排
const box = imgEl.parentNode;
if (box && box.classList.contains('image-zoom-container')) {
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) { }
}
function extractBgUrl(el) {
if (!el || el.nodeType !== 1) return null;
let m = (el.getAttribute('style') || '').match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/i);
let url = m ? m[1] : null;
if (!url) {
try {
const bg = getComputedStyle(el).backgroundImage;
if (bg && bg !== 'none') {
m = bg.match(/url\(\s*['"]?([^'")]+)['"]?\s*\)/i);
if (m) url = m[1];
}
} catch (e) { }
}
return url ? cleanBgUrl(url) : null;
}
function cleanBgUrl(url) {
let u = url.trim().replace(/^['"]|['"]$/g, '');
if (/alicdn\.com/i.test(u)) {
let prev;
do {
prev = u;
u = u.replace(/(_!![\w\-.,]+?\.(?:jpg|jpeg|png|webp))_[\w.\-]+$/i, '$1')
.replace(/\.(jpg|jpeg|png|webp)_[\w.]+$/i, '.$1')
.replace(/_\.(webp|jpg|jpeg|png)$/i, '');
} while (u !== prev);
return u;
}
let prev;
do {
prev = u;
u = u.replace(/![\w\-]+$/i, '')
.replace(/_\d+x\d+(q\d+)?\.(jpg|jpeg|png|webp)(\.\w+)?$/i, '')
.replace(/\.(jpg|jpeg|png|webp)_[\w.]+$/i, '.$1')
.replace(/_\.(webp|jpg|jpeg|png)$/i, '')
.replace(/\.webp$/i, '.jpg');
} while (u !== prev);
return u;
}
// 5.6.23 image crop helper: moved verbatim from 5.6.22.
function cropBlackBars(imgEl) {
try {
if (imgEl.dataset.zoomCropped) return;
const w = imgEl.naturalWidth, h = imgEl.naturalHeight;
if (!w || !h) return;
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(imgEl, 0, 0);
let data;
try { data = ctx.getImageData(0, 0, w, h).data; } catch (e) { return; }
let transparentCount = 0;
const total = w * h, step = Math.max(1, Math.floor(total / 20000));
let sampled = 0;
for (let i = 0; i < total; i += step) {
if (data[i * 4 + 3] < 10) transparentCount++;
sampled++;
}
if (transparentCount / sampled > 0.05) return;
const threshold = 24;
const isContent = (i) => data[i + 3] > 10 &&
(data[i] > threshold || data[i + 1] > threshold || data[i + 2] > threshold);
const rowHas = (y0) => {
for (let x0 = 0; x0 < w; x0++) if (isContent((y0 * w + x0) * 4)) return true;
return false;
};
const colHas = (x0) => {
for (let y0 = 0; y0 < h; y0++) if (isContent((y0 * w + x0) * 4)) return true;
return false;
};
let top = 0;
while (top < h && !rowHas(top)) top++;
if (top === h) return;
let bottom = h - 1;
while (bottom > top && !rowHas(bottom)) bottom--;
let left = 0;
while (left < w && !colHas(left)) left++;
let right = w - 1;
while (right > left && !colHas(right)) right--;
top = Math.min(top, Math.floor(h * 0.25));
bottom = Math.max(bottom, h - 1 - Math.floor(h * 0.25));
left = Math.min(left, Math.floor(w * 0.25));
right = Math.max(right, w - 1 - Math.floor(w * 0.25));
const cw = right - left + 1, ch = bottom - top + 1;
if (cw >= w * 0.9 && ch >= h * 0.9) return;
if (cw < 20 || ch < 20) return;
const out = document.createElement('canvas');
out.width = cw;
out.height = ch;
out.getContext('2d').drawImage(imgEl, left, top, cw, ch, 0, 0, cw, ch);
imgEl.dataset.zoomCropped = '1';
out.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
if (imgEl.__zoomBlobUrl) URL.revokeObjectURL(imgEl.__zoomBlobUrl);
imgEl.__zoomBlobUrl = url;
imgEl.src = url;
// 容器尺寸保持不变(尊重当前模式的尺寸语义),
// 只按新宽高比在容器内做 contain 重排
const box = imgEl.parentNode;
if (box && box.classList.contains('image-zoom-container')) {
const bw = box.clientWidth, bh = box.clientHeight;
const ratio = cw / ch;
let nw = bw, nh = Math.round(bw / ratio);
if (nh > bh) { nh = bh; nw = Math.round(bh * ratio); }
imgEl.style.width = nw + 'px';
imgEl.style.height = nh + 'px';
imgEl.style.left = Math.round((bw - nw) / 2) + 'px';
imgEl.style.top = Math.round((bh - nh) / 2) + 'px';
}
}, 'image/jpeg', 0.92);
} catch (e) { }
}
// ================
// 7. 站点规则(背景图模式)
// ================
const SITE_HOVER_PROXY_RULES = [
{
domains: ['taobao.com', 'tmall.com'],
itemSelector: '.img-wrapper',
cardSelector: '.tb-pick-content-item, li',
pollInterval: 300
}
];
function getCustomRules() {
try { return storageGet('image_zoom_custom_rules', []); } catch (e) { return []; }
}
function saveCustomRules(rules) {
storageSet('image_zoom_custom_rules', rules);
}
// ================
// 背景图悬停模块
// ================
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: r.itemSelector,
cardSelector: r.cardSelector,
pollInterval: Math.max(100, parseInt(r.pollInterval) || 300)
}));
const domainMatch = (r) => r.domains.some(d => currentDomain === d || currentDomain.endsWith('.' + d));
const rule = customRules.find(domainMatch) || SITE_HOVER_PROXY_RULES.find(domainMatch);
if (!rule) return;
let lastBgCard = null, bgDelayTimer = null;
setInterval(() => {
if (document.hidden) return;
if (!pointerInWindow) return; // ★ 光标不在浏览器内,不做背景图识别
if (!isEnabled || isHomepageZoomDisabled()) return;
if (zoomFSM.hasActiveZoom()) return;
const x = lastMouse.x, y = lastMouse.y;
if (x < 0) return;
const el = document.elementFromPoint(x, y);
if (!el || !el.closest) return;
let wrapper = el.closest(rule.itemSelector);
if (!wrapper) {
const card0 = el.closest(rule.cardSelector);
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)) || wrapper;
if (card !== lastBgCard) {
lastBgCard = card;
bgZoomLayer.hide();
if (bgDelayTimer) clearTimeout(bgDelayTimer);
const mx = x, my = y;
bgDelayTimer = setTimeout(() => {
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);
}
}, config.delay);
}
} else {
const card = el.closest(rule.cardSelector);
if (!card || !card.querySelector(rule.itemSelector)) {
if (bgDelayTimer) { clearTimeout(bgDelayTimer); bgDelayTimer = null; }
bgZoomLayer.hide();
lastBgCard = null;
}
}
}, rule.pollInterval);
}
// ================
// 8. 背景图自动识别兜底
// ================
const bgZoomLayer = (function() {
let container = null, url = null;
function hide() {
url = null;
if (!container) return;
const c = container;
container = null;
const im = c.querySelector('img');
if (im) im.style.transform = 'scale(.6)';
c.style.opacity = '0';
setTimeout(() => c.remove(), 280);
}
function show(loadUrls, zOffset) {
if (container && !container.isConnected) { container = null; url = null; }
if (container && url === loadUrls.cleaned) return;
hide();
zoomFSM.dispatch('RESET'); // 背景图接管前硬重置 FSM,避免悬空引用
const c = document.createElement('div');
c.className = 'image-zoom-container';
c.dataset.izOwner = 'bg';
c.style.cssText = `position:fixed;inset:0;z-index:${config.zoomZIndex - (zOffset || 1)};opacity:0;
transition:all .3s ease;pointer-events:none;display:flex;justify-content:center;align-items:center;
padding:20px;box-sizing:border-box;`;
const big = document.createElement('img');
big.style.cssText = `max-width:${Math.min(window.innerWidth - 60, config.maxWidth)}px;
max-height:${Math.min(window.innerHeight - 60, config.maxHeight)}px;object-fit:contain;border-radius:8px;
box-shadow:0 4px 20px rgba(0,0,0,.2);transform:scale(.6);transition:transform .35s cubic-bezier(.34,1.56,.64,1);`;
let triedFallback = false;
big.onerror = () => {
if (!triedFallback && loadUrls.raw && loadUrls.raw !== big.src) {
triedFallback = true;
big.src = loadUrls.raw;
return;
}
hide();
};
big.onload = () => requestAnimationFrame(() => {
c.style.opacity = '1';
big.style.transform = 'scale(1)';
});
big.src = loadUrls.cleaned;
c.appendChild(big);
document.body.appendChild(c);
container = c;
url = big.src;
}
return { show, hide };
})();
function setupAutoBackgroundHover() {
let bgTimer = null, pendingUrl = null;
const cancelBg = () => {
if (bgTimer) { clearTimeout(bgTimer); bgTimer = null; }
pendingUrl = null;
};
document.addEventListener('mouseover', throttleLeading((e) => {
if (!isEnabled || isHomepageZoomDisabled()) { cancelBg(); bgZoomLayer.hide(); return; }
if (config.avoidClickConflict && isImageInLightboxMode()) { cancelBg(); bgZoomLayer.hide(); return; }
if (e.target.closest && e.target.closest('#zoomDockZone, #izModalOverlay, #izIntroOverlay, #izUpdateNotice, .image-zoom-container')) return;
if (isHoverBlocker(e.target)) { cancelBg(); bgZoomLayer.hide(); return; }
if (zoomFSM.hasActiveZoom()) { cancelBg(); return; }
const x = e.clientX, y = e.clientY;
// 光标下已有合格 img → 不做背景图识别
if (pickVisibleImgUnderPoint(x, y)) { cancelBg(); return; }
if (!(e.target instanceof Element)) { cancelBg(); return; }
let node = e.target, bgEl = null, bgFromSibling = false;
while (node && node !== document.body) {
try {
const bg = getComputedStyle(node).backgroundImage;
if (bg && bg !== 'none' && bg.includes('url(')) { bgEl = node; break; }
} catch (err) { }
if (node.querySelectorAll) {
const kids = node.querySelectorAll('*');
if (kids.length <= 500) {
for (const kid of kids) {
if (kid === node) continue;
try {
const bg2 = getComputedStyle(kid).backgroundImage;
if (bg2 && bg2 !== 'none' && bg2.includes('url(')) {
const kr = kid.getBoundingClientRect();
if (kr.width >= 120 && kr.height >= 120 && inRect(x, y, kr)) {
bgEl = kid;
bgFromSibling = true;
break;
}
}
} catch (err) { }
}
if (bgEl) break;
}
}
node = node.parentElement;
}
if (!bgEl) { cancelBg(); bgZoomLayer.hide(); return; }
if (!bgFromSibling) {
const cs = getComputedStyle(bgEl);
if (cs.backgroundRepeat.split(' ').some(v => v.startsWith('repeat') && v !== 'no-repeat')) {
cancelBg(); bgZoomLayer.hide(); return;
}
if (cs.backgroundPosition !== '0% 0%' && cs.backgroundSize !== 'cover' &&
cs.backgroundSize !== 'contain' && cs.backgroundSize !== '100% 100%') {
cancelBg(); bgZoomLayer.hide(); return;
}
}
// 占屏接近全屏的背景基本是装饰底图,放大只会挡住页面内容,不是用户意图
try {
const br = bgEl.getBoundingClientRect();
const vw = window.innerWidth, vh = window.innerHeight;
const isDecorative = (br.width >= vw * 0.7 && br.height >= vh * 0.6) || // 宽屏 hero/banner
(br.width >= vw * 0.5 && br.height >= vh * 0.85) || // 竖向大背景
(br.width * br.height >= vw * vh * 0.5); // 面积过半屏
if (isDecorative || getComputedStyle(bgEl).backgroundAttachment === 'fixed') {
cancelBg(); bgZoomLayer.hide(); return;
}
} catch (err) { }
const url = extractBgUrl(bgEl);
if (!url) { cancelBg(); bgZoomLayer.hide(); return; }
if (url === pendingUrl && bgTimer) return;
cancelBg();
pendingUrl = url;
if (config.delay <= 0) {
bgZoomLayer.show({ cleaned: url, raw: url }, 2);
return;
}
bgTimer = setTimeout(() => {
bgTimer = null;
const r = bgEl.isConnected ? bgEl.getBoundingClientRect() : null;
if (!inRect(lastMouse.x, lastMouse.y, r) || zoomFSM.hasActiveZoom()) {
pendingUrl = null;
return;
}
bgZoomLayer.show({ cleaned: url, raw: url }, 2);
}, config.delay);
}, 100), true);
document.addEventListener('mouseout', (e) => {
if (!e.relatedTarget) { cancelBg(); bgZoomLayer.hide(); }
}, true);
}
// 5.6.38 - 图片登记处理器
// 仅从 main.js 原位置整体迁移,不改 processImage 行为。
// ================
// 4. 图片登记
// ================
function processImage(img) {
if (!isEnabled || !img || !img.parentNode) 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. 图片登记(已拆分至 image/processor.js)
// ================
// ================
// 5. ★ 核心:单实例状态机 zoomFSM(已拆分至 core/zoom-fsm.js)
// ================
// 6. ★ 全局事件流 + 停稳裁决器
// =============================
// 🔴 CORE PROTECTION ZONE
// target resolve / zoom self filtering
// =============================
function pickVisibleImgUnderPoint(x, y) {
let stack = [];
try { stack = document.elementsFromPoint(x, y) || []; } catch (e) { }
for (const el of stack) {
// 🔴 防止自身放大层污染 elementsFromPoint 结果
if (el && el.closest && el.closest('.image-zoom-container')) { continue; }
if (el.tagName !== 'IMG') {
// ★ 从顶往下扫,先碰到菜单/空白占位 → 判定无图,不再穿透
if (el !== document.body && el !== document.documentElement && isHoverBlocker(el)) return null;
continue;
}
if (!isImgVisibleNow(el)) continue;
const r = el.getBoundingClientRect();
if (r.width >= config.minOriginalSize && r.height >= config.minOriginalSize &&
r.width >= 10 && r.height >= 10 && inRect(x, y, r)) {
return el;
}
}
return null;
}
// ★ 统一裁决:光标位置 (x,y) 下是什么?派发 HOVER / HOVER_NONE / 保持
// 被 mouseover 流(事件坐标)和停稳裁决器(lastMouse 坐标)共用
function resolveCursorTarget(x, y, t) {
if (!isEnabled || isHomepageZoomDisabled()) { zoomFSM.dispatch('DISMISS'); return; }
// 网页自身进入灯箱后:旧的 hover 放大立即收起,且灯箱内部图片不再触发新的放大。
if (isImageInLightboxMode(t)) {
zoomFSM.dispatch('HOVER_NONE', { x, y, force: true });
return;
}
if (t && t.closest && t.closest('#zoomDockZone, #izModalOverlay, #izIntroOverlay, #izUpdateNotice')) {
zoomFSM.dispatch('HOVER_NONE', { x, y });
return;
}
if (t && t.closest && t.closest('.image-zoom-container')) return;
let img = null;
// 路径A:target 是 img(可见 + 在矩形内 + 未被裁出可视区)
if (t && t.tagName === 'IMG' && isImgVisibleNow(t) &&
inRect(x, y, t.getBoundingClientRect()) && !isImgClippedAway(t)) img = t;
// 路径B:命中栈选图
if (!img) img = pickVisibleImgUnderPoint(x, y);
// 路径B+:stretched-link / card-link 整卡链接桥接
if (!img && t && t.closest) {
const link = t.closest('a.stretched-link, a.card-link');
if (link) {
let node = link.parentElement, hops = 0;
while (node && node !== document.body && hops < 4) {
for (const cImg of node.querySelectorAll('img')) {
// ★ 自有 UI 隔离:放大层 img 不作为桥接目标
if (cImg.closest && cImg.closest('.image-zoom-container')) continue;
if (isImgVisibleNow(cImg) && !isImgClippedAway(cImg) && inRect(x, y, cImg.getBoundingClientRect())) {
img = cImg;
break;
}
}
if (img) break;
node = node.parentElement;
hops++;
}
}
}
// 路径B++:同一 <a> 内的标题/文本桥接
// 仅在链接内恰好存在 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)) {
zoomFSM.dispatch('HOVER_NONE', { x, y });
return;
}
const scope = (t && t.closest && t.closest('a, article, section, li, picture, div[class]')) || document;
const imgs = scope.querySelectorAll('img');
for (const im of imgs) {
// ★ 自有 UI 隔离:scope=document 时会扫到放大层自身 img,
// 否则将自举 HOVER → 对放大图再开实例(图上叠图)
if (im.closest && im.closest('.image-zoom-container')) continue;
const r = im.getBoundingClientRect();
if (r.width >= 80 && r.height >= 80 &&
r.width >= config.minOriginalSize && r.height >= config.minOriginalSize &&
inRect(x, y, r) && isImgVisibleNow(im) && !isImgClippedAway(im)) {
img = im;
break;
}
}
}
if (img) {
if (!img.classList.contains('image-zoom-processed')) processImage(img);
if (img.classList.contains('image-zoom-processed')) {
zoomFSM.dispatch('HOVER', { img, x, y });
return;
}
}
zoomFSM.dispatch('HOVER_NONE', { x, y });
}
// ================
// 5. ★ 核心:单实例状态机 zoomFSM
// ================
const zoomFSM = (function() {
const S = Object.freeze({ IDLE:'IDLE', PENDING:'PENDING', SHOWING:'SHOWING', ACTIVE:'ACTIVE', FADING:'FADING' });
let state = S.IDLE;
let instance = null; // 唯一活实例
let 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; }
pendingImg = null;
pendingFails = 0;
pendingGraceUsed = false;
}
function fadeOutContainer(container) {
if (!container || !container.parentNode) return;
container.dataset.izFading = '1';
container.style.opacity = '0';
const zImg = container.querySelector('img');
if (zImg) {
zImg.style.transform = 'scale(.6)';
zImg.style.opacity = '0';
if (zImg.__zoomBlobUrl) {
URL.revokeObjectURL(zImg.__zoomBlobUrl);
zImg.__zoomBlobUrl = null;
}
}
setTimeout(() => {
if (container.parentNode) container.parentNode.removeChild(container);
}, FADE_MS);
}
function computeAdaptiveSize(img, rect) {
const availW = Math.min(window.innerWidth - 60, config.maxWidth);
const availH = Math.min(window.innerHeight - 60, config.maxHeight);
const size = Math.sqrt(rect.width * rect.height);
const availSize = Math.sqrt(availW * availH);
const TARGET_MIN = Math.min(1200, availSize);
let target = Math.min(Math.max(size * 5, TARGET_MIN), availSize);
let scale = Math.min(Math.max(target / size, 1), 10);
let w = Math.round(rect.width * scale), h = Math.round(rect.height * scale);
const rawW = w, rawH = h;
if (w > availW || h > availH) {
const fit = Math.min(availW / w, availH / h);
w = Math.round(w * fit);
h = Math.round(h * fit);
}
return { w, h, rawW, rawH };
}
function createInstance(img) {
try {
const rect = img.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
const isSmallImg = rect.width < config.smallImgThreshold || rect.height < config.smallImgThreshold;
let boxW, boxH, imgScale = 1;
if (config.zoomMode === 'adaptive') {
const size = computeAdaptiveSize(img, rect);
boxW = size.w;
boxH = size.h;
imgScale = 1; // 容器已按图片原始比例校正,scale=1 → 完整显示不裁切
} else if (isSmallImg) {
const effScale = Math.max(config.scale || 1, config.minScale || 1);
const targetW = Math.max(config.smallImgWidth, rect.width * effScale);
const targetH = Math.max(config.smallImgHeight, rect.height * effScale);
const fit = Math.min(targetW / rect.width, targetH / rect.height);
boxW = Math.min(Math.round(rect.width * fit), config.maxWidth);
boxH = Math.min(Math.round(rect.height * fit), config.maxHeight);
} else {
const effScale = Math.max(config.scale || 1, config.minScale || 1);
boxW = Math.round(rect.width * effScale);
boxH = Math.round(rect.height * effScale);
if (boxW > config.maxWidth || boxH > config.maxHeight) {
const fit = Math.min(config.maxWidth / boxW, config.maxHeight / boxH);
boxW = Math.round(boxW * fit);
boxH = Math.round(boxH * fit);
}
}
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
const natRatio = img.naturalWidth / img.naturalHeight;
const boxRatio = boxW / boxH;
if (natRatio > boxRatio) boxH = Math.round(boxW / natRatio);
else boxW = Math.round(boxH * natRatio);
}
const imgW = Math.round(boxW * imgScale), imgH = Math.round(boxH * imgScale);
const offX = Math.round((boxW - imgW) / 2), offY = Math.round((boxH - imgH) / 2);
const zi = img.__zoomHasClick ? config.zoomZIndex - 1 : config.zoomZIndex;
const container = document.createElement('div');
container.className = 'image-zoom-container';
container.dataset.izOwner = 'fsm';
container.style.cssText = `position:fixed;z-index:${zi};opacity:0;transition:opacity .3s ease;
pointer-events:none;left:50%;top:50%;transform:translate(-50%,-50%);
width:${boxW}px;height:${boxH}px;box-sizing:border-box;border-radius:10px;overflow:hidden;`;
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:contain;transition:opacity .3s ease,transform .35s cubic-bezier(.34,1.56,.64,1);
box-shadow:0 4px 20px rgba(0,0,0,.2);display:block;border-radius:10px;transform:scale(.6);opacity:0;`;
const fallbackSrc = img.src || img.currentSrc;
let hiResSrc = null;
if (!(currentDomain === 'jd.com' || currentDomain.endsWith('.jd.com'))) {
hiResSrc = upgradeImgUrl((fallbackSrc || '')
.replace(/\/s\d+x\d+_/g, '/')
.replace(/\.avif$/i, '')) || null;
}
if (hiResSrc === fallbackSrc) hiResSrc = null;
const inst = {
container, imgEl: zoomedImg, sourceImg: img,
fallbackSrc, usingHiRes: false, revealed: false
};
zoomedImg.onload = () => {
if (instance !== inst) return; // 过期实例回调直接丢弃
cropBlackBars(zoomedImg);
if (!inst.revealed) {
inst.revealed = true;
FSM.dispatch('LOADED', inst);
}
};
zoomedImg.onerror = () => {
if (instance === inst) FSM.dispatch('ERROR', inst);
};
zoomedImg.src = fallbackSrc;
if (hiResSrc) {
const probe = new Image();
probe.onload = () => {
if (!zoomedImg.isConnected || zoomedImg.src === hiResSrc) return;
if (zoomedImg.__zoomBlobUrl) {
URL.revokeObjectURL(zoomedImg.__zoomBlobUrl);
zoomedImg.__zoomBlobUrl = null;
}
delete zoomedImg.dataset.zoomCropped;
inst.usingHiRes = true;
zoomedImg.src = hiResSrc;
};
probe.src = hiResSrc;
}
if (zoomedImg.complete && zoomedImg.naturalWidth > 0 && !inst.revealed) {
inst.revealed = true;
setTimeout(() => {
if (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;
pendingTimer = setTimeout(() => FSM.dispatch('TIMER_FIRE', img), config.delay);
state = S.PENDING;
},
cancel() {
clearPending();
state = S.IDLE;
},
show(img) {
const inst = createInstance(img);
if (!inst) {
state = S.IDLE;
wheelManager.sync();
return;
}
instance = inst;
state = S.SHOWING;
wheelManager.sync();
},
activate(inst) {
if (inst !== instance) return;
inst.container.style.opacity = '1';
inst.imgEl.style.transform = 'scale(1)';
inst.imgEl.style.opacity = '1';
// 容器超出视口时提示可滚动查看(1200ms 后自动消失,避免遮挡)
if (inst.container.offsetHeight > window.innerHeight && !inst.toastShown) {
inst.toastShown = true;
showToast('图片超出屏幕,滚动滚轮查看其余部分', 800);
}
state = S.ACTIVE;
},
onError(inst) {
if (inst !== instance) return;
if (inst.usingHiRes) {
inst.usingHiRes = false;
inst.imgEl.src = inst.fallbackSrc;
return;
}
fadeOutContainer(inst.container);
instance = null;
state = S.IDLE;
wheelManager.sync();
},
beginFade() {
clearPending();
if (instance) {
fadeOutContainer(instance.container);
instance = null;
}
state = S.FADING;
setTimeout(() => { if (state === S.FADING) state = S.IDLE; }, FADE_MS);
wheelManager.sync();
},
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; },
getActiveRect() { return instance ? instance.container.getBoundingClientRect() : null; },
heartbeat() {
if (!isEnabled || isHomepageZoomDisabled()) { FSM.dispatch('RESET'); return; }
// ★ 光标不在浏览器内:不维持任何状态(mouseout 可能未送达,这里兜底)
if (!pointerInWindow) {
if (state === S.PENDING) actions.cancel();
else if ((state === S.SHOWING || state === S.ACTIVE) && instance) actions.beginFade();
return;
}
const x = lastMouse.x, y = lastMouse.y;
if (x < 0) return;
if (state === S.PENDING && pendingImg) {
if (canTriggerNow(pendingImg, x, y)) {
pendingFails = 0;
return;
}
pendingFails++;
if (pendingFails >= 2) actions.cancel();
return;
}
if (state === S.SHOWING || state === S.ACTIVE) {
if (!instance) { state = S.IDLE; return; }
// 只要在原图区域内就保持;离开原图(即使在放大图上方)→ 淡出
const sRect = instance.sourceImg.isConnected ? instance.sourceImg.getBoundingClientRect() : null;
if (!inRect(x, y, sRect)) actions.beginFade();
return;
}
},
orphanCheck() {
if (instance && instance.sourceImg && !instance.sourceImg.isConnected) actions.beginFade();
},
dispatch(event, payload) {
switch (event) {
case 'HOVER': {
const img = payload.img, x = payload.x, y = payload.y;
if (!isEnabled || isHomepageZoomDisabled()) break;
if (config.avoidClickConflict && isImageInLightboxMode()) break;
if (!img || !img.isConnected) break;
const anchor = canTriggerNow(img, x, y);
if (!anchor) {
if (state === S.PENDING && pendingImg === img) actions.cancel();
break;
}
if (state === S.SHOWING || state === S.ACTIVE) {
if (!instance) break;
if (img === instance.sourceImg) break; // 还是当前这张 → 保持不动
const zr = instance.container.getBoundingClientRect();
if (inRect(x, y, zr)) {
if (Date.now() - lastMouse.t < 300) {
actions.beginFade();
actions.startPending(img);
}
break;
}
actions.beginFade();
actions.startPending(img);
break;
}
if (state === S.PENDING) {
if (pendingImg !== img) actions.startPending(img);
} else {
actions.startPending(img); // IDLE / FADING
}
break;
}
case 'HOVER_NONE': {
const x = payload.x, y = payload.y;
// force=true 时跳过"lastMouse 在原图矩形内"的保活检查:
// 鼠标离开浏览器/窗口失焦时 lastMouse 是陈旧坐标,光标实际已不在图上
if (!(payload && payload.force) && (state === S.SHOWING || state === S.ACTIVE) && instance) {
const sr = instance.sourceImg.isConnected ? instance.sourceImg.getBoundingClientRect() : null;
if (inRect(x, y, sr)) break;
}
if (state === S.PENDING) actions.cancel();
else if (state === S.SHOWING || state === S.ACTIVE) actions.beginFade();
break;
}
case 'TIMER_FIRE': {
if (state === S.PENDING && pendingImg === payload) {
if (canTriggerNow(payload, lastMouse.x, lastMouse.y)) {
actions.show(payload);
break;
}
const img = payload;
const r = img.isConnected ? img.getBoundingClientRect() : null;
const stillOk = r && isImgVisibleNow(img) && !isImgClippedAway(img) &&
lastMouse.x >= r.left - 120 && lastMouse.x <= r.right + 120 &&
lastMouse.y >= r.top - 120 && lastMouse.y <= r.bottom + 120;
if (stillOk && !pendingGraceUsed) {
pendingGraceUsed = true;
pendingFails = 0;
if (pendingTimer) clearTimeout(pendingTimer);
pendingTimer = setTimeout(() => FSM.dispatch('TIMER_FIRE', img), Math.max(300, config.delay));
} else {
actions.cancel();
}
}
break;
}
case 'LOADED':
actions.activate(payload);
break;
case 'ERROR':
actions.onError(payload);
break;
case 'WHEEL': {
if (state === S.ACTIVE && instance && isEnabled) {
payload.preventDefault && payload.preventDefault();
payload.stopPropagation && payload.stopPropagation();
lastWheelEvent = payload;
if (!wheelTicking) {
wheelTicking = true;
requestAnimationFrame(() => {
wheelTicking = false;
if (lastWheelEvent && state === S.ACTIVE && instance) actions.pan(lastWheelEvent);
});
}
return true;
}
return false;
}
case 'DISMISS': {
if (state === S.PENDING) actions.cancel();
else if (state === S.SHOWING || state === S.ACTIVE) actions.beginFade();
break;
}
case 'RESET': {
clearPending();
if (instance) {
const c = instance.container;
const zImg = c.querySelector('img');
if (zImg && zImg.__zoomBlobUrl) {
URL.revokeObjectURL(zImg.__zoomBlobUrl);
zImg.__zoomBlobUrl = null;
}
if (c.parentNode) c.parentNode.removeChild(c);
instance = null;
}
document.querySelectorAll('.image-zoom-container[data-iz-owner="fsm"]').forEach(c => {
const zImg = c.querySelector('img');
if (zImg && zImg.__zoomBlobUrl) {
URL.revokeObjectURL(zImg.__zoomBlobUrl);
zImg.__zoomBlobUrl = null;
}
c.remove();
});
state = S.IDLE;
wheelManager.sync();
break;
}
}
return false;
}
};
return FSM;
})();
const wheelManager = (function() {
let attached = false;
function handler(e) {
if (zoomFSM.dispatch('WHEEL', e)) return;
bilibiliVolumeModule.onWheel(e);
}
function sync() {
const need = zoomFSM.hasActiveZoom() || bilibiliVolumeModule.isFullscreenActive();
if (need && !attached) {
document.addEventListener('wheel', handler, { capture: true, passive: false });
attached = true;
} else if (!need && attached) {
document.removeEventListener('wheel', handler, { capture: true });
attached = false;
}
}
document.addEventListener('fullscreenchange', sync);
document.addEventListener('webkitfullscreenchange', sync);
return { sync };
})();
function setupGlobalHoverStream() {
// 流1:mouseover(前沿节流 + 事件自带坐标)—— 快速、跟随移动
document.addEventListener('mouseover', throttleLeading((e) => {
if (lastMouse.x < 0) {
lastMouse.x = e.clientX;
lastMouse.y = e.clientY;
}
resolveCursorTarget(e.clientX, e.clientY, e.target);
}, 60), true);
// 流2:★ 停稳裁决器 —— 鼠标停止移动 ~120ms 后主动裁决一次。
// 兜底 mouseover 被节流丢弃 / 未派发 / target 不含 img 的所有情况。
const stopResolve = debounce(() => {
if (document.hidden) return;
if (!pointerInWindow) return; // ★ 光标已被其他窗口覆盖,不裁决
const x = lastMouse.x, y = lastMouse.y;
if (x < 0) return;
resolveCursorTarget(x, y, document.elementFromPoint(x, y));
}, 120);
document.addEventListener('mousemove', () => { stopResolve(); }, { passive: true });
// 鼠标真正离开窗口(移到文件管理器等原生窗口上)
document.addEventListener('mouseout', (e) => {
if (!e.relatedTarget) {
pointerInWindow = false;
zoomFSM.dispatch('HOVER_NONE', { x: lastMouse.x, y: lastMouse.y, force: true });
}
}, true);
// ★ 窗口失焦(Alt+Tab 切到其他应用 / 点击其他窗口)→ 可配置地强制淡出。
// 与 mouseout 同理:光标未移动时 DOM 无感知,但视线已离开浏览器。
// 不改 pointerInWindow——光标物理上可能仍在页面内(如点击独立 devtools 窗口),
// mousemove 会照常到达并自愈。开关关闭时不做任何处理(对照查看模式)
window.addEventListener('blur', () => {
if (!config.blurDismiss) return;
zoomFSM.dispatch('HOVER_NONE', { x: lastMouse.x, y: lastMouse.y, force: true });
});
}
function setupHeartbeat() {
//后台标签页跳过心跳。
// heartbeat/orphanCheck 每 150ms/1500ms 都做 getBoundingClientRect(强制 layout),
// 之前在后台标签页持续空转,耗电并干扰主线程;
// 后台布局变化(SPA 预渲染、轮播定时器)还可能触发错误的 beginFade 决策。
setInterval(() => {
if (!document.hidden) zoomFSM.heartbeat();
}, HEARTBEAT_MS);
setInterval(() => {
if (!document.hidden) zoomFSM.orphanCheck();
}, 1500);
}
// 6. ★ 全局事件流 + 停稳裁决器(已拆分至 core/hover-stream.js)
// ================
// 7/8. ★ 背景图悬停模块(已拆分至 image/background-hover.js)
// ================
// ================
// 9. 动态图片观察器(已拆分至 core/observer.js)
// ================
// 5.6.25 - 动态图片观察器
// 仅从 main.js 原位置整体迁移,不改观察/MutationObserver 行为。
// ================
// 9. 动态图片观察器
// ================
let lazyImageObserver = null;
function observeImage(img) {
if (!lazyImageObserver) {
lazyImageObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
lazyImageObserver.unobserve(entry.target);
processImage(entry.target);
}
});
}, { rootMargin: '300px 0px' });
}
lazyImageObserver.observe(img);
}
function initImages() {
if (isHomepageZoomDisabled()) return;
document.querySelectorAll('img:not(.image-zoom-processed)').forEach(observeImage);
}
function startObserver() {
let processingQueue = false;
let pendingMutations = null;
const observer = new MutationObserver(mutations => {
if (!isEnabled || isHomepageZoomDisabled()) return;
//处理期间到达的 mutation 不再丢弃。
if (processingQueue) {
if (!pendingMutations) pendingMutations = [];
for (const m of mutations) pendingMutations.push(m); // ★ 避免大数组 spread 的 RangeError
return;
}
processingQueue = true;
const processBatch = (batch) => {
const nodes = new Set();
batch.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.target.tagName === 'IMG' &&
!mutation.target.classList.contains('image-zoom-processed')) {
nodes.add(mutation.target);
} else if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
if (node.tagName === 'IMG' && !node.classList.contains('image-zoom-processed')) {
nodes.add(node);
} else if (node.querySelectorAll) {
node.querySelectorAll('img:not(.image-zoom-processed)').forEach(i => nodes.add(i));
}
});
}
});
nodes.forEach(img => observeImage(img));
};
const run = (batch) => {
requestAnimationFrame(() => {
processBatch(batch);
processingQueue = false;
if (pendingMutations && pendingMutations.length) {
const nextBatch = pendingMutations;
pendingMutations = null;
processingQueue = true;
run(nextBatch);
}
});
};
run(mutations);
});
observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'data-src', 'srcset'] });
return observer;
}
// ============================================================================
// Lightbox observer
// 处理网页自己的点击放大 / Lightbox:
// 1. 灯箱打开时收起已有 hover 放大实例;
// 2. 灯箱内部图片不再触发 hover 放大;
// 3. 兼容“仅插入灯箱 DOM、不修改 body class”的网站实现。
// 4. 兼容没有固定 class 的通用全屏图片浮层。
// ============================================================================
const LIGHTBOX_CLASSES = ['lightbox-open', 'fancybox-open', 'modal-open', 'zoom-overlay-open'];
// 某些站点的灯箱结构为 #imgzoom > #imgzoom_zoomlayer > #imgzoom_zoom。
const LIGHTBOX_SELECTORS = [
'.fancybox-container', '.fancybox-overlay', '.fancybox-bg', '.fancybox__container',
'.pswp', '.pswp__bg', '.lg-backdrop', '.lg-outer',
'.viewer-container', '.viewer-backdrop', '[data-fancybox-container]',
'[aria-modal="true"]',
'#imgzoom', '#imgzoom_zoomlayer', '#imgzoom_zoom'
];
function isVisibleLightboxElement(el) {
if (!el || !el.isConnected) return false;
try {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
if (parseFloat(cs.opacity) < 0.05) return false;
const r = el.getBoundingClientRect();
return r.width >= 120 && r.height >= 120;
} catch (e) { return false; }
}
function hasLightboxKeyword(el) {
if (!el || !el.getAttribute) return false;
const cls = typeof el.className === 'string' ? el.className : (el.getAttribute('class') || '');
const id = el.id || '';
const text = `${cls} ${id}`.toLowerCase();
return /lightbox|fancybox|photoswipe|photo[-_ ]?viewer|image[-_ ]?(viewer|preview)|gallery[-_ ]?(modal|viewer)|preview[-_ ]?modal/.test(text);
}
function isElementInsideLightbox(target) {
if (!target || !target.closest) return false;
try {
if (target.closest(LIGHTBOX_SELECTORS.join(','))) return true;
if (target.closest('[role="dialog"],[aria-modal="true"]')) return true;
} catch (e) { }
let node = target.nodeType === Node.ELEMENT_NODE ? target : target.parentElement;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth++ < 14) {
try {
const cs = getComputedStyle(node);
const positioned = cs.position === 'fixed' || cs.position === 'absolute';
const z = cs.zIndex === 'auto' ? 0 : parseFloat(cs.zIndex);
const r = node.getBoundingClientRect();
const large = r.width >= Math.max(320, window.innerWidth * 0.45) ||
r.height >= Math.max(240, window.innerHeight * 0.45);
const fullscreenish = r.width >= window.innerWidth * 0.75 && r.height >= window.innerHeight * 0.65;
const containsImage = !!(node.querySelector && node.querySelector('img'));
const keyword = hasLightboxKeyword(node);
if (positioned && z >= 100 && isVisibleLightboxElement(node) && containsImage && (keyword || large || fullscreenish)) {
return true;
}
// 常见“全屏遮罩 + 内容层”结构:容器本身可能没有关键词,
// 但内部已有一张大图,因此再做一层更严格的兜底。
if (positioned && z >= 100 && fullscreenish && containsImage) return true;
} catch (e) { }
node = node.parentElement;
}
return false;
}
function isImageInLightboxMode(target) {
if (LIGHTBOX_CLASSES.some(c =>
document.body.classList.contains(c) ||
document.documentElement.classList.contains(c)
)) return true;
if (target && isElementInsideLightbox(target)) return true;
for (const selector of LIGHTBOX_SELECTORS) {
try {
const nodes = document.querySelectorAll(selector);
for (const el of nodes) {
if (isVisibleLightboxElement(el)) return true;
}
} catch (e) { }
}
try {
const nodes = document.querySelectorAll(
'[class*="lightbox"][class*="open" i],' +
'[class*="lightbox"][class*="active" i],' +
'[class*="fancybox"][class*="open" i],' +
'[class*="viewer"][class*="open" i]'
);
for (const el of nodes) {
if (isVisibleLightboxElement(el)) return true;
}
} catch (e) { }
return false;
}
function setupLightboxObserver() {
const dismissIfOpen = () => {
if (isImageInLightboxMode()) zoomFSM.dispatch('DISMISS');
};
new MutationObserver(dismissIfOpen).observe(document.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class', 'style', 'aria-hidden', 'aria-modal']
});
document.addEventListener('click', (e) => {
const t = e.target;
if (!t || !t.closest) return;
const trigger = t.closest(
'img,[data-fancybox],[data-lightbox],.zoomable,.lightbox,.gallery-item,.fancybox,' +
'[onclick*="zoom"],[onclick*="lightbox"],[onclick*="gallery"],[onclick*="preview"]'
);
if (!trigger) return;
zoomFSM.dispatch('DISMISS');
setTimeout(dismissIfOpen, 0);
}, true);
dismissIfOpen();
}
// setupLightboxObserver 已拆分至 core/lightbox-observer.js
// ================
// 10. 滚轮管理器
// ================
// ================
// Bilibili player helper. Behavior kept identical to 5.6.26.
const bilibiliVolumeModule = (function() {
let enabled = storageGet('bilibili_volume_enabled', true);
let toast = null;
function isInFullscreenMode() {
if (document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement) return true;
if (document.body.classList.contains('player-mode-webfullscreen')) return true;
const player = document.querySelector('.bpx-player-container');
return !!(player && player.classList.contains('state-fullscreen'));
}
function findVideoElement() {
const fe = document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement;
if (fe) {
const v = fe.querySelector('video');
if (v) return v;
}
return document.querySelector('.bpx-player-container video, video');
}
function applyVolume(video, v) {
const c = Math.max(0, Math.min(1, v));
try {
const p = window.player;
if (p && typeof p.setVolume === 'function') {
p.setVolume(Math.round(c * 100));
if (c > 0) { typeof p.setMute === 'function' ? p.setMute(false) : (video.muted = false); }
return;
}
} catch (e) { }
video.volume = c;
video.muted = false;
}
function getVolume(video) {
try {
const p = window.player;
if (p && typeof p.getVolume === 'function') return p.getVolume() / 100;
} catch (e) { }
return video.volume;
}
function volIcon(volume) {
if (volume === 0) return `<svg width="28" height="28" viewBox="0 0 1024 1024"><path d="M64 362.67v298.66h198.33L512 911V113L262.33 362.67H64zM736 512c0-43.56-11.28-83.22-33.83-119-22.56-35.78-52.5-63-89.83-81.67v399c37.33-17.11 67.28-43.55 89.83-79.33C724.72 595.22 736 555.56 736 512z" fill="currentColor"></path><path d="M704.5 320.5l-384 384M320.5 320.5l384 384" stroke="currentColor" stroke-width="56" stroke-linecap="round" fill="none"></path></svg>`;
return `<svg width="28" height="28" viewBox="0 0 1024 1024"><path d="M64 362.67v298.66h198.33L512 911V113L262.33 362.67H64zM736 512c0-43.56-11.28-83.22-33.83-119-22.56-35.78-52.5-63-89.83-81.67v399c37.33-17.11 67.28-43.55 89.83-79.33C724.72 595.22 736 555.56 736 512zM612.33 75.67v102.67c71.56 21.78 130.67 63.39 177.33 124.83 46.67 61.44 70 131.06 70 208.83 0 77.78-23.33 147.39-70 208.83C743 782.28 683.89 823.89 612.33 845.66v102.67C677.67 932.78 736.78 904 789.67 862s94.5-93.33 124.83-154S960 582 960 512s-15.17-135.33-45.5-196c-30.34-60.67-71.94-112-124.83-154s-112-70.78-177.34-86.33z" fill="currentColor"></path></svg>`;
}
function showVolumeToast(volume) {
if (!toast) {
toast = document.createElement('div');
toast.id = 'bilibili-volume-toast';
toast.style.cssText = `position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);
background:rgba(255,255,255,.9);color:#333;padding:8px 16px;border-radius:8px;z-index:2147483647;
font-size:26px;font-weight:300;opacity:0;transition:opacity .3s;pointer-events:none;
box-shadow:0 4px 20px rgba(0,0,0,.2);min-width:90px;text-align:center;backdrop-filter:blur(10px);
display:flex;align-items:center;justify-content:center;gap:8px;`;
}
const parent = document.fullscreenElement || document.body;
if (!toast.parentNode || toast.parentNode !== parent) {
if (toast.parentNode) toast.parentNode.removeChild(toast);
parent.appendChild(toast);
}
toast.innerHTML = volIcon(volume) + `<span>${volume === 0 ? '静音' : Math.round(volume * 100) + '%'}</span>`;
toast.style.opacity = '1';
if (toast.timeoutId) clearTimeout(toast.timeoutId);
toast.timeoutId = setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => { if (toast && toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
}, 2000);
}
function onWheel(e) {
if (!enabled || !isInFullscreenMode()) return false;
const video = findVideoElement();
if (!video) return false;
e.stopPropagation();
e.preventDefault();
const target = Math.max(0, Math.min(1, getVolume(video) + (e.deltaY > 0 ? -0.02 : 0.02)));
applyVolume(video, target);
showVolumeToast(target);
return true;
}
function handleKeydown(e) {
if (!enabled) return;
const tag = (e.target.tagName || '').toLowerCase();
if (tag === 'input' || tag === 'textarea' || e.target.isContentEditable) return;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'].includes(e.code) && isInFullscreenMode()) e.preventDefault();
}
function init() {
if (!window.location.hostname.includes('bilibili.com')) return;
if (enabled) {
window.addEventListener('keydown', handleKeydown);
document.addEventListener('volumechange', (e) => {
if (!enabled || e.target.tagName !== 'VIDEO' || !isInFullscreenMode()) return;
showVolumeToast(e.target.muted ? 0 : e.target.volume);
}, { capture: true });
}
}
return {
init, onWheel,
isFullscreenActive: isInFullscreenMode,
get isEnabled() { return enabled; },
setEnabled(v) { enabled = v; storageSet('bilibili_volume_enabled', v); }
};
})();
// 10. Bilibili 播放器辅助(已拆分至 sites/bilibili.js)
// ================
// wheelManager 已拆分至 core/wheel-manager.js
// ================
// 11. 样式 / 悬浮按钮 / 配置面板 / 反馈 / 自定义规则
// ================
let styleElement = null, dockStyleElement = null;
function injectStyles() {
if (!dockStyleElement) {
const s = document.createElement('style');
s.textContent = `
#zoomDockZone{position:fixed;right:0;top:50%;transform:translateY(-50%);width:110px;height:120px;z-index:100000;pointer-events:none}
#zoomDockZone.open{pointer-events:auto}
#zoomDock{position:absolute;right:-20px;top:10px;width:44px;height:44px;border-radius:22px 0 0 22px;background:rgba(52,211,153,.20)!important;backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(52,211,153,.32)!important;border-right:none;box-shadow:0 2px 8px rgba(0,0,0,.18)!important;display:flex;align-items:center;justify-content:center;padding-left:2px;transition:right .35s cubic-bezier(.34,1.56,.64,1),opacity .3s ease,background .3s ease;opacity:.6;pointer-events:auto;cursor:pointer;z-index:100001}
#zoomDockZone.open #zoomDock{right:0;opacity:1}
#zoomDock.off{background:rgba(239,68,68,.20)!important;border-color:rgba(239,68,68,.32)!important}
#zoomDock.off .icon-svg{opacity:.7}
#zoomDock.hp{background:rgba(255,152,0,.22)!important;border-color:rgba(255,152,0,.35)!important}
#zoomDockZone.open #zoomDock:hover{filter:brightness(1.25)}
.icon-svg{width:20px;height:20px;fill:none;stroke:rgba(255,255,255,.92);stroke-width:2;stroke-linecap:round;stroke-linejoin:round;filter:drop-shadow(0 2px 4px rgba(0,0,0,.2));pointer-events:none;transition:transform .35s cubic-bezier(.34,1.56,.64,1);transform:translateX(-8px)}
#zoomDockZone.open .icon-svg{transform:translateX(0)}
#statusDot{position:absolute;right:6px;top:50%;transform:translateY(-50%);width:6px;height:6px;border-radius:50%;background:#34d399;box-shadow:0 0 12px rgba(52,211,153,.5);transition:background .3s,box-shadow .3s;opacity:.7;pointer-events:none}
#zoomDock.off #statusDot{background:#f87171;box-shadow:0 0 12px rgba(248,113,113,.5)}
#zoomDock.hp #statusDot{background:#ffb74d;box-shadow:0 0 12px rgba(255,152,0,.5)}
#zoomDockZone.open #statusDot{opacity:1}
#zoomSettings{position:absolute;right:0;top:62px;width:32px;height:32px;border-radius:16px 0 0 16px;background:rgba(20,24,44,.75)!important;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.14)!important;border-right:none;box-shadow:0 4px 20px rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;cursor:pointer;opacity:0;transform:translateX(18px) scale(.9);pointer-events:none;transition:all .35s cubic-bezier(.34,1.56,.64,1) .06s;z-index:100002}
#zoomDockZone.open #zoomSettings{opacity:1;transform:translateX(0) scale(1);pointer-events:auto}
#zoomSettings:hover{background:rgba(30,36,64,.85)!important;border-color:rgba(251,191,36,.35)!important;box-shadow:0 4px 28px rgba(251,191,36,.18)}
#zoomSettings .icon-svg--gear{width:16px;height:16px;stroke:rgba(255,255,255,.9);stroke-width:2;fill:none;transition:stroke .3s,transform .6s ease;pointer-events:none}
#zoomSettings:hover .icon-svg--gear{stroke:#fbbf24;transform:rotate(60deg)}
.zoom-bubble-tip{position:fixed;background:rgba(20,20,40,.80);backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);border:1px solid rgba(255,255,255,.08);color:rgba(255,255,255,.90);padding:6px 16px;border-radius:10px;font-size:12px;font-weight:450;letter-spacing:.3px;font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .25s ease .2s;z-index:100003;box-shadow:0 8px 30px rgba(0,0,0,.4)}
.zoom-bubble-tip::after{content:'';position:absolute;right:-6px;top:50%;transform:translateY(-50%);border:6px solid transparent;border-left-color:rgba(20,20,40,.80);border-right:0}
.zoom-bubble-tip.visible{opacity:1}
body.zoom-dock-dragging,body.zoom-dock-dragging *{transition:none!important;cursor:grabbing!important;user-select:none!important}
body.zoom-dock-dragging #zoomDock{cursor:grabbing!important}
#izModalOverlay{position:fixed;inset:0;z-index:99999;display:none;align-items:center;justify-content:center;padding:24px;background:rgba(15,23,42,.45);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px)}
#izModalOverlay.anim-in{animation:izOverlayFade .35s ease}
#izModalOverlay.anim-out{animation:izOverlayFadeOut .3s ease forwards}
@keyframes izOverlayFade{from{opacity:0}to{opacity:1}}
@keyframes izOverlayFadeOut{from{opacity:1}to{opacity:0}}
#izConfigPanel{width:100%;max-width:560px;max-height:90vh;background:rgba(255,255,255,.88);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-radius:28px;box-shadow:0 25px 60px -12px rgba(0,0,0,.35),0 0 0 1px rgba(255,255,255,.6) inset;overflow:hidden;animation:izPanelSlide .40s cubic-bezier(.16,1,.3,1);display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;box-sizing:border-box}
@keyframes izPanelSlide{from{opacity:0;transform:translateY(28px) scale(.96)}to{opacity:1;transform:translateY(0) scale(1)}}
.iz-panel-scroll{flex:1;overflow-y:auto;padding:0 28px 12px 28px;scroll-behavior:smooth}
.iz-panel-scroll::-webkit-scrollbar{width:4px}
.iz-panel-scroll::-webkit-scrollbar-track{background:transparent}
.iz-panel-scroll::-webkit-scrollbar-thumb{background:#cbd5e1;border-radius:8px}
.iz-panel-header{display:flex;align-items:center;justify-content:space-between;padding:20px 28px 0 28px;flex-shrink:0}
.iz-panel-header-left{display:flex;align-items:center;gap:12px}
.iz-panel-icon{width:38px;height:38px;background:linear-gradient(135deg,#4F46E5,#7C3AED);border-radius:12px;display:flex;align-items:center;justify-content:center;color:#fff;font-size:20px;flex-shrink:0;box-shadow:0 4px 12px rgba(79,70,229,.3)}
.iz-panel-title{font-size:20px;font-weight:600;color:#0F172A;letter-spacing:-.3px}
.iz-panel-title span{font-weight:400;color:#64748B;font-size:14px;margin-left:6px}
.iz-close-btn{width:36px;height:36px;border:none;background:rgba(203,213,225,.4);border-radius:50%;cursor:pointer;font-size:18px;color:#64748B;display:flex;align-items:center;justify-content:center;transition:all .2s;flex-shrink:0;line-height:1}
.iz-close-btn:hover{background:rgba(239,68,68,.12);color:#EF4444;transform:rotate(90deg)}
.iz-section{margin-top:20px;background:rgba(255,255,255,.5);border-radius:18px;padding:18px 20px 20px 20px;border:1px solid rgba(226,232,240,.7)}
.iz-section-title{font-size:13px;font-weight:600;color:#64748B;letter-spacing:.6px;margin-bottom:14px;display:flex;align-items:center;gap:8px}
.iz-badge{background:#4F46E5;color:#fff;font-size:10px;font-weight:600;padding:0 8px;border-radius:20px;line-height:18px}
.iz-row{display:flex;align-items:center;gap:14px;margin-bottom:14px}
.iz-row:last-child{margin-bottom:0}
.iz-row-label{font-size:14px;font-weight:500;color:#1E293B;flex-shrink:0;min-width:100px}
.iz-row-label .iz-hint{font-weight:400;font-size:12px;color:#94A3B8;display:block;margin-top:1px}
.iz-row-control{flex:1;min-width:0}
.iz-select{width:100%;padding:8px 36px 8px 14px;font-size:14px;font-weight:500;color:#0F172A;background:#fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M6 8L1 3h10z'/%3E%3C/svg%3E") no-repeat right 14px center;background-size:12px;border:1.5px solid #E2E8F0;border-radius:12px;appearance:none;-webkit-appearance:none;transition:all .2s;cursor:pointer;outline:none;height:42px}
.iz-select:hover{border-color:#A5B4FC}
.iz-select:focus{border-color:#4F46E5;box-shadow:0 0 0 3px rgba(79,70,229,.15)}
.iz-input-group{display:flex;align-items:center;background:#fff;border:1.5px solid #E2E8F0;border-radius:12px;overflow:hidden;transition:all .2s;height:42px}
.iz-input-group:focus-within{border-color:#4F46E5;box-shadow:0 0 0 3px rgba(79,70,229,.15)}
.iz-input-group input[type="number"]{flex:1;border:none;padding:0 12px;font-size:14px;font-weight:500;color:#0F172A;background:transparent;outline:none;min-width:0;height:100%;width:100%;-moz-appearance:textfield}
.iz-input-group input[type="number"]::-webkit-inner-spin-button,.iz-input-group input[type="number"]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
.iz-input-group .iz-unit{padding:0 14px 0 4px;font-size:13px;color:#94A3B8;font-weight:500;flex-shrink:0}
.iz-input-group.disabled-group{opacity:.6;background-color:#f8fafc;border-color:#e2e8f0;cursor:not-allowed}
.iz-input-group.disabled-group input{cursor:not-allowed;background-color:#f8fafc}
.iz-checkbox-wrap{display:flex;align-items:center;gap:12px;cursor:pointer;user-select:none}
.iz-checkbox-custom{width:20px;height:20px;flex-shrink:0;border:2px solid #CBD5E1;border-radius:6px;background:#fff;transition:all .2s;display:flex;align-items:center;justify-content:center}
.iz-checkbox-custom.checked{background:#4F46E5;border-color:#4F46E5}
.iz-checkbox-custom.checked::after{content:"✓";color:#fff;font-size:14px;font-weight:700;line-height:1}
.iz-checkbox-label{font-size:14px;font-weight:500;color:#1E293B}
.iz-checkbox-label .iz-sub{font-weight:400;font-size:12px;color:#94A3B8;display:block;margin-top:1px}
.iz-toggle-wrap{display:flex;align-items:center;gap:12px;cursor:pointer;user-select:none}
.iz-toggle{position:relative;width:46px;height:28px;flex-shrink:0;background:#CBD5E1;border-radius:20px;transition:all .3s cubic-bezier(.34,1.56,.64,1);box-shadow:inset 0 1px 3px rgba(0,0,0,.1)}
.iz-toggle.active{background:linear-gradient(135deg,#4F46E5,#7C3AED)}
.iz-toggle .iz-knob{position:absolute;top:3px;left:3px;width:22px;height:22px;background:#fff;border-radius:50%;transition:all .3s cubic-bezier(.34,1.56,.64,1);box-shadow:0 2px 6px rgba(0,0,0,.18)}
.iz-toggle.active .iz-knob{left:21px}
.iz-exclusion-box{background:rgba(241,245,249,.7);border-radius:14px;padding:14px 16px;border:1px solid rgba(226,232,240,.5)}
.iz-exclusion-box .iz-status-row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.iz-exclusion-box .iz-status-text{font-size:14px;font-weight:500;display:flex;align-items:center;gap:8px;color:#1E293B}
.iz-exclusion-box .iz-dot{display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0}
.iz-exclusion-box .iz-dot.on{background:#10B981}
.iz-exclusion-box .iz-dot.off{background:#F59E0B}
.iz-exclusion-note{margin-top:8px;font-size:12px;color:#64748B}
.iz-btn-sm{padding:6px 16px;border:none;border-radius:10px;font-size:13px;font-weight:600;cursor:pointer;transition:all .2s;flex-shrink:0;height:34px}
.iz-btn-sm.primary{background:#4F46E5;color:#fff}
.iz-btn-sm.primary:hover{background:#4338CA;transform:translateY(-1px);box-shadow:0 4px 12px rgba(79,70,229,.3)}
.iz-btn-sm.warning{background:#F59E0B;color:#fff}
.iz-btn-sm.warning:hover{background:#D97706;transform:translateY(-1px);box-shadow:0 4px 12px rgba(245,158,11,.3)}
.iz-collapse-header{display:flex;align-items:center;justify-content:space-between;padding:10px 0 6px 0;cursor:pointer;user-select:none;border-top:1px solid rgba(226,232,240,.5);margin-top:4px;transition:opacity .2s}
.iz-collapse-header .iz-left{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:#1E293B}
.iz-collapse-header .iz-arrow{transition:transform .3s ease;font-size:12px;color:#94A3B8}
.iz-collapse-header .iz-arrow.open{transform:rotate(90deg)}
.iz-badge-params{font-size:11px;font-weight:500;color:#64748B;background:#F1F5F9;padding:2px 10px;border-radius:20px}
.iz-collapse-body{overflow:hidden;max-height:0;opacity:0;transition:all .35s cubic-bezier(.16,1,.3,1)}
.iz-collapse-body.open{max-height:800px;opacity:1;padding-top:12px}
.iz-param-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}
.iz-param-item{display:flex;flex-direction:column;gap:4px}
.iz-param-item label{font-size:12px;font-weight:500;color:#64748B;letter-spacing:.2px;display:flex;align-items:center;gap:5px}
.iz-tip-icon{display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;flex-shrink:0;border-radius:50%;background:#E2E8F0;color:#64748B;font-size:10px;font-weight:700;line-height:1;cursor:help;position:relative;transition:all .2s}
.iz-tip-icon:hover{background:#4F46E5;color:#fff}
#izTipBubble{position:fixed;width:240px;background:rgba(15,23,42,.95);color:#F1F5F9;font-size:12px;font-weight:400;line-height:1.6;padding:10px 13px;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.3);opacity:0;pointer-events:none;transition:opacity .15s ease;z-index:100005;white-space:normal;text-align:left}
.iz-param-item .iz-input-group{height:36px}
.iz-param-item .iz-input-group input[type="number"]{font-size:13px;padding:0 10px}
.iz-param-item .iz-input-group .iz-unit{font-size:12px;padding:0 10px 0 2px}
.iz-panel-footer{padding:14px 28px 20px 28px;border-top:1px solid rgba(226,232,240,.5);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;background:rgba(255,255,255,.4);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.iz-btn-ghost{background:none;border:none;padding:8px 14px;font-size:13px;font-weight:500;color:#64748B;cursor:pointer;border-radius:10px;transition:all .2s}
.iz-btn-ghost:hover{background:rgba(239,68,68,.08);color:#EF4444}
.iz-btn-ghost:active{transform:scale(.96)}
.iz-btn-primary-solid{padding:10px 28px;background:linear-gradient(135deg,#4F46E5,#7C3AED);border:none;border-radius:14px;font-size:14px;font-weight:600;color:#fff;cursor:pointer;transition:all .25s;box-shadow:0 4px 16px rgba(79,70,229,.3)}
.iz-btn-primary-solid:hover{transform:translateY(-2px);box-shadow:0 8px 28px rgba(79,70,229,.4)}
.iz-btn-primary-solid:active{transform:scale(.96)}
#izIntroOverlay{position:fixed;inset:0;z-index:100010;display:none;align-items:center;justify-content:center;padding:24px;background:rgba(15,23,42,.45);backdrop-filter:blur(10px);-webkit-backdrop-filter:blur(10px);pointer-events:auto}
#izIntroPanel{pointer-events:auto}
#izUpdateNotice{position:fixed;top:22px;right:22px;width:min(360px,calc(100vw - 44px));z-index:100011;display:none;background:rgba(255,255,255,.92);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border:1px solid rgba(226,232,240,.82);border-radius:20px;box-shadow:0 18px 50px -12px rgba(15,23,42,.28),0 0 0 1px rgba(255,255,255,.55) inset;overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;box-sizing:border-box;pointer-events:auto}
#izUpdateNotice.anim-in{animation:izUpdateSlide .35s cubic-bezier(.16,1,.3,1)}
#izUpdateNotice.anim-out{animation:izUpdateFadeOut .25s ease forwards}
#izUpdateNotice .iz-update-head{display:flex;align-items:center;justify-content:space-between;padding:16px 18px 10px 18px}
#izUpdateNotice .iz-update-title{display:flex;align-items:center;gap:9px;font-size:15px;font-weight:600;color:#0F172A}
#izUpdateNotice .iz-update-icon{width:30px;height:30px;border-radius:10px;background:linear-gradient(135deg,#4F46E5,#7C3AED);display:flex;align-items:center;justify-content:center;color:#fff;font-size:15px;box-shadow:0 4px 12px rgba(79,70,229,.22)}
#izUpdateNotice .iz-update-version{font-size:11px;font-weight:600;color:#6366F1;background:#EEF2FF;padding:4px 8px;border-radius:999px}
#izUpdateNotice .iz-update-close{width:28px;height:28px;border:none;background:rgba(203,213,225,.45);border-radius:50%;cursor:pointer;font-size:15px;color:#64748B;display:flex;align-items:center;justify-content:center;line-height:1}
#izUpdateNotice .iz-update-close:hover{background:rgba(239,68,68,.12);color:#EF4444}
#izUpdateNotice .iz-update-body{padding:0 18px 14px 18px;font-size:12.5px;line-height:1.75;color:#64748B}
#izUpdateNotice .iz-update-item{padding:8px 10px;background:rgba(248,250,252,.72);border:1px solid rgba(226,232,240,.68);border-radius:10px;margin-top:7px}
#izUpdateNotice .iz-update-item:first-child{margin-top:0}
#izUpdateNotice .iz-update-footer{display:flex;justify-content:flex-end;padding:12px 18px 14px;border-top:1px solid rgba(226,232,240,.5);background:rgba(255,255,255,.45)}
#izUpdateNotice .iz-update-ok{padding:8px 16px;font-size:12px;border-radius:11px}
@keyframes izUpdateSlide{from{opacity:0;transform:translateY(-12px) scale(.98)}to{opacity:1;transform:translateY(0) scale(1)}}
@keyframes izUpdateFadeOut{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-8px)}}
#izIntroOverlay.anim-in{animation:izOverlayFade .35s ease}
#izIntroOverlay.anim-out{animation:izOverlayFadeOut .3s ease forwards}
#izIntroPanel{width:100%;max-width:560px;max-height:90vh;background:rgba(255,255,255,.90);backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);border-radius:28px;box-shadow:0 25px 60px -12px rgba(0,0,0,.35),0 0 0 1px rgba(255,255,255,.6) inset;overflow:hidden;animation:izPanelSlide .40s cubic-bezier(.16,1,.3,1);display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;box-sizing:border-box}
.iz-intro-scroll{flex:1;overflow-y:auto;padding:0 28px 20px 28px}
.iz-intro-lead{margin-top:18px;padding:14px 16px;background:linear-gradient(135deg,rgba(79,70,229,.08),rgba(124,58,237,.06));border:1px solid rgba(99,102,241,.14);border-radius:16px;font-size:13px;line-height:1.75;color:#475569}
.iz-intro-item{margin-top:12px;padding:14px 16px;background:rgba(248,250,252,.72);border:1px solid rgba(226,232,240,.7);border-radius:14px}
.iz-intro-item-title{font-size:14px;font-weight:600;color:#1E293B;margin-bottom:5px;display:flex;align-items:center;gap:7px}
.iz-intro-item-text{font-size:12.5px;line-height:1.75;color:#64748B}
.iz-intro-footer{padding:14px 28px 20px 28px;border-top:1px solid rgba(226,232,240,.5);display:flex;justify-content:flex-end;align-items:center;flex-shrink:0;background:rgba(255,255,255,.4);backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.iz-intro-ok{min-width:118px}
@media (max-width:600px){#izIntroPanel{border-radius:20px;max-height:95vh}.iz-intro-scroll{padding:0 18px 16px}.iz-intro-footer{padding:12px 18px 16px}.iz-intro-ok{width:100%}}
@media (max-width:600px){#izUpdateNotice{top:12px;right:12px;width:calc(100vw - 24px);border-radius:16px}}
@media (max-width:600px){#izConfigPanel{border-radius:20px;max-height:95vh}.iz-panel-scroll{padding:0 18px 8px 18px}.iz-panel-header{padding:16px 18px 0 18px}.iz-panel-footer{padding:12px 18px 16px 18px;flex-wrap:wrap;gap:10px}.iz-row{flex-direction:column;align-items:stretch;gap:6px}.iz-param-grid{grid-template-columns:1fr}.iz-panel-title{font-size:17px}.iz-panel-icon{width:34px;height:34px;font-size:17px}}
`;
document.head.appendChild(s);
dockStyleElement = s;
}
if (styleElement) return;
const style = document.createElement('style');
style.textContent = `
.image-zoom-container img{object-fit:contain}
.image-zoom-hover{cursor:zoom-in!important}
a.image-zoom-hover,.cover-container.image-zoom-hover,.card.image-zoom-hover{cursor:zoom-in!important}
a.stretched-link.image-zoom-hover{cursor:zoom-in!important}
`;
document.head.appendChild(style);
styleElement = style;
}
// 全局样式注入已拆分至 ui/styles.js
let toggleButton = null, gearButton = null, dockZone = null, dockTip = null, settingsTip = null;
function createDockButton() {
dockZone = document.createElement('div');
dockZone.id = 'zoomDockZone';
toggleButton = document.createElement('div');
toggleButton.id = 'zoomDock';
toggleButton.innerHTML = `<svg class="icon-svg" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><line x1="16" y1="16" x2="21" y2="21"/></svg><span id="statusDot"></span>`;
toggleButton.title = '点击:切换图片放大';
gearButton = document.createElement('div');
gearButton.id = 'zoomSettings';
gearButton.innerHTML = `<svg class="icon-svg--gear" viewBox="0 0 24 24"><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`;
gearButton.title = '打开配置面板';
dockZone.appendChild(toggleButton);
dockZone.appendChild(gearButton);
dockTip = document.createElement('div');
dockTip.className = 'zoom-bubble-tip';
settingsTip = document.createElement('div');
settingsTip.className = 'zoom-bubble-tip';
settingsTip.textContent = '配置面板';
document.body.appendChild(dockZone);
document.body.appendChild(dockTip);
document.body.appendChild(settingsTip);
const savedTop = storageGet(`image_zoom_dock_top_${currentDomain}`);
if (savedTop !== undefined && savedTop !== null) {
dockZone.style.transform = 'none';
dockZone.style.top = savedTop + 'px';
}
let leaveTimer = null;
function positionTips() {
const dr = toggleButton.getBoundingClientRect(), gr = gearButton.getBoundingClientRect();
dockTip.style.top = (dr.top + dr.height / 2) + 'px';
dockTip.style.right = (window.innerWidth - dr.left + 8) + 'px';
dockTip.style.transform = 'translateY(-50%)';
dockTip.classList.add('visible');
settingsTip.style.top = (gr.top + gr.height / 2) + 'px';
settingsTip.style.right = (window.innerWidth - gr.left + 8) + 'px';
settingsTip.style.transform = 'translateY(-50%)';
settingsTip.classList.add('visible');
}
function hideTips() {
dockTip.classList.remove('visible');
settingsTip.classList.remove('visible');
}
function updateTipText() {
if (isHomepageZoomDisabled()) dockTip.innerHTML = '主页已禁用图片放大 <span style="opacity:.5">设置中可开启</span>';
else if (!isEnabled) dockTip.innerHTML = '图片放大已关闭 <span style="opacity:.4">点击开启</span>';
else dockTip.innerHTML = '图片放大已开启 <span style="opacity:.4">点击关闭</span>';
}
toggleButton.addEventListener('mouseenter', () => {
clearTimeout(leaveTimer);
dockZone.classList.add('open');
updateTipText();
positionTips();
setTimeout(positionTips, 320);
});
dockZone.addEventListener('mouseenter', () => clearTimeout(leaveTimer));
dockZone.addEventListener('mouseleave', () => {
leaveTimer = setTimeout(() => {
if (dockZone.matches(':hover')) return;
dockZone.classList.remove('open');
hideTips();
}, 200);
});
toggleButton.addEventListener('click', (e) => {
e.stopPropagation();
if (isHomepageZoomDisabled()) {
showToast('当前网站主页已禁用图片放大,可在设置面板中开启');
return;
}
toggleEnabled();
updateTipText();
});
gearButton.addEventListener('click', (e) => {
e.stopPropagation();
toggleConfigPanel();
});
// 拖拽(区分点击与拖动)
(function() {
let isDragging = false, hasMoved = false, startY = 0, startTop = 0, dragJustEnded = false;
const THRESHOLD = 3;
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
function syncY(topPx) {
dockZone.style.top = clamp(topPx, 0, window.innerHeight - toggleButton.offsetHeight) + 'px';
}
toggleButton.addEventListener('click', function(e) {
if (dragJustEnded) {
e.stopImmediatePropagation();
e.preventDefault();
dragJustEnded = false;
}
}, true);
toggleButton.addEventListener('mousedown', function(e) {
if (e.button !== 0) return;
isDragging = true;
hasMoved = false;
startY = e.clientY;
startTop = dockZone.getBoundingClientRect().top;
e.preventDefault();
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
const delta = e.clientY - startY;
if (!hasMoved && Math.abs(delta) > THRESHOLD) {
hasMoved = true;
document.body.classList.add('zoom-dock-dragging');
}
if (hasMoved) syncY(startTop + delta);
});
document.addEventListener('mouseup', function() {
if (!isDragging) return;
isDragging = false;
document.body.classList.remove('zoom-dock-dragging');
if (hasMoved) {
dockZone.style.transform = 'none';
storageSet(`image_zoom_dock_top_${currentDomain}`, parseFloat(dockZone.style.top) || 0);
dragJustEnded = true;
setTimeout(() => { dragJustEnded = false; }, 50);
}
});
})();
updateButtonState();
}
// ★ Dock 悬浮控制区已拆分至 ui/dock.js
// ----- 配置面板 -----
// ================
function updateButtonState() {
if (!toggleButton) return;
if (isHomepageZoomDisabled()) {
toggleButton.classList.remove('off');
toggleButton.classList.add('hp');
} else if (!isEnabled) {
toggleButton.classList.remove('hp');
toggleButton.classList.add('off');
} else {
toggleButton.classList.remove('off', 'hp');
}
}
function toggleEnabled() {
isEnabled = !isEnabled;
storageSet(`image_zoom_enabled_${currentDomain}`, isEnabled);
updateButtonState();
if (isEnabled) {
injectStyles();
initImages();
} else {
zoomFSM.dispatch('RESET');
bgZoomLayer.hide();
}
}
function toggleHomepageDisabled() {
const disabled = !isHomepageDisabled();
storageSet(`image_zoom_homepage_disabled_${currentDomain}`, disabled);
if (disabled && isHomepage() && isEnabled) {
zoomFSM.dispatch('RESET');
bgZoomLayer.hide();
}
showToast(disabled ? '已禁用当前网站主页的图片放大功能' : '已启用当前网站主页的图片放大功能');
updateButtonState();
refreshPanelHomepageSection();
}
function refreshPanelHomepageSection() {
const overlay = document.getElementById('izModalOverlay');
if (!overlay) return;
const disabled = isHomepageDisabled();
const dot = overlay.querySelector('#izHpDot'), txt = overlay.querySelector('#izHpText'), btn = overlay.querySelector('#izHpToggleBtn');
if (dot) dot.className = disabled ? 'iz-dot off' : 'iz-dot on';
if (txt) txt.textContent = disabled ? '当前主页已禁用图片放大' : '当前主页已启用图片放大';
if (btn) {
btn.textContent = disabled ? '启用主页图片放大功能' : '禁用主页图片放大功能';
btn.className = disabled ? 'iz-btn-sm primary' : 'iz-btn-sm warning';
}
}
// ================
// ★ 首次使用说明(全局仅显示一次,不按域名保存)
// ================
const INTRO_SEEN_KEY = 'image_zoom_intro_seen';
function showIntroPanel(force = false) {
let overlay = document.getElementById('izIntroOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'izIntroOverlay';
overlay.innerHTML = `
<div id="izIntroPanel">
<div class="iz-panel-header">
<div class="iz-panel-header-left">
<div class="iz-panel-icon">🔍</div>
<div class="iz-panel-title">欢迎使用<span>· 图片悬停放大</span></div>
</div>
<button class="iz-close-btn" id="izIntroClose" title="关闭">✕</button>
</div>
<div class="iz-intro-scroll">
<div class="iz-intro-lead">把鼠标停在图片上,脚本会在短暂停留后自动显示放大预览。多数情况下无需额外操作,图片会优先使用页面已有的高清资源。</div>
<div class="iz-intro-item">
<div class="iz-intro-item-title">🖱️ 怎么使用</div>
<div class="iz-intro-item-text">将鼠标停在想看的图片上即可。智能自适应会根据屏幕空间自动调整大小,也可以在设置中切换为固定倍数模式。</div>
</div>
<div class="iz-intro-item">
<div class="iz-intro-item-title">⚠️ 使用时注意</div>
<div class="iz-intro-item-text">不是鼠标经过的每张图都会放大:太小的图片、网站自己的点击/弹窗区域、部分特殊页面结构可能会被跳过。主页也可以单独禁用放大。</div>
</div>
<div class="iz-intro-item">
<div class="iz-intro-item-title">🛠️ 遇到问题怎么办</div>
<div class="iz-intro-item-text">先检查右侧悬浮按钮是否开启,再打开设置调整“触发延迟”和“避免与点击放大功能冲突”。特殊网站无法识别时,可使用设置中的自定义规则;修改后建议刷新页面。</div>
</div>
<div class="iz-intro-item">
<div class="iz-intro-item-title">🎬 B站提示</div>
<div class="iz-intro-item-text">如果在 B 站全屏播放时发现滚轮不能正常调音量,可在设置中开启“B站播放器辅助”。</div>
</div>
</div>
<div class="iz-intro-footer">
<button class="iz-btn-primary-solid iz-intro-ok" id="izIntroOk">知道了,开始使用</button>
</div>
</div>`;
document.body.appendChild(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 showUpdateNotice() {
if (storageGet(UPDATE_SEEN_KEY, false)) return;
let notice = document.getElementById('izUpdateNotice');
if (!notice) {
notice = document.createElement('div');
notice.id = 'izUpdateNotice';
notice.innerHTML = `
<div class="iz-update-head">
<div class="iz-update-title">
<div class="iz-update-icon">✨</div>
<div>版本更新说明</div>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span class="iz-update-version">v${UPDATE_VERSION}</span>
<button class="iz-update-close" id="izUpdateClose" title="关闭">✕</button>
</div>
</div>
<div class="iz-update-body">
<div class="iz-update-item">🚀 自 5.6.11 以来,持续增强图片悬停放大、动态图片、背景图与网页灯箱兼容性。</div>
<div class="iz-update-item">🧩 完成核心功能的渐进式模块化:配置、状态机、悬停解析、图片处理、观察器、站点适配及 UI 已拆分。</div>
<div class="iz-update-item">🛡️ 优化窗口失焦、Lightbox、同链接标题桥接等兼容场景,同时保持原有放大体验。</div>
<div class="iz-update-item">🧰 为后续浏览器扩展迁移做好结构准备。</div>
</div>
<div class="iz-update-footer">
<button class="iz-btn-primary-solid iz-update-ok" id="izUpdateOk">知道了</button>
</div>`;
document.body.appendChild(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(已拆分至 ui/config-panel.js)
// ================
// ============================================================================
// Config Panel UI module
// 仅搬运 5.6.28 原有配置面板代码;不改运行行为。
// ============================================================================
const COMMON_PARAM_DEFS = [
{ key: 'delay', label: '悬停延迟', unit: 'ms', min: 0, max: 2000, step: 100, tip: '鼠标停在图片上多久后才放大。数值越小响应越快,越大越不容易误触发。建议 300~800ms' },
{ key: 'minOriginalSize', label: '最小放大尺寸', unit: 'px', min: 0, max: 500, step: 5, tip: '原图宽或高小于此值时不放大,用来过滤网站里的小图标、表情、按钮图标等。设为 0 表示全部放大' },
{ key: 'maxWidth', label: '大图最大宽度', unit: 'px', min: 300, max: 3000, step: 100, tip: '放大后的大图宽度上限。自适应模式下它决定了放大画布的宽度上限,调小后大图整体变小;固定模式下大图最多放大到这个宽度' },
{ key: 'maxHeight', label: '大图最大高度', unit: 'px', min: 300, max: 3000, step: 100, tip: '放大后的大图高度上限。自适应模式下它决定了放大画布的高度上限,调小后大图整体变小;固定模式下大图最多放大到这个高度' },
{ 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: '固定模式下,小图放大时使用的目标框高度基准;最终保持原图比例,并与目标宽度共同决定实际显示尺寸' }
];
const FEEDBACK_API = 'https://damp-woodpecker-4867.ydgg123.deno.net';
function postToAPI(payload) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: FEEDBACK_API,
headers: { 'Content-Type': 'application/json' },
data: JSON.stringify(payload),
timeout: 15000,
onload: (r) => (r.status >= 200 && r.status < 400) ? resolve() : reject(new Error('提交失败(' + r.status + ')')),
onerror: () => reject(new Error('网络错误,请稍后重试')),
ontimeout: () => reject(new Error('提交超时,请检查网络'))
});
});
}
function submitFeedback(text) {
return postToAPI({
text: text,
page: location.hostname + location.pathname + ' | 脚本 v' + (typeof GM_info !== 'undefined' && GM_info.script ? GM_info.script.version : 'unknown')
});
}
function submitSiteRule(rule, note) {
return postToAPI({
type: 'site_rule',
rule: {
name: rule.name, domains: rule.domains, imgMode: rule.imgMode,
itemSelector: rule.itemSelector, cardSelector: rule.cardSelector, pollInterval: rule.pollInterval
},
note: note || '',
context: {
page: location.hostname + location.pathname,
scriptVersion: (typeof GM_info !== 'undefined' && GM_info.script) ? GM_info.script.version : 'unknown',
userAgent: navigator.userAgent,
timestamp: Date.now()
}
});
}
// 参数保存提示已由 showToast / showSaveToast 统一路由到面板中央
const notifyConfigSaved = debounce((key, value, label) => {
showSaveToast(`已保存:${label || key} = ${value}`);
}, 600);
function injectFeedbackSection(overlay) {
const scroll = overlay.querySelector('.iz-panel-scroll');
if (!scroll || scroll.querySelector('#izFeedbackSection')) return;
const section = document.createElement('div');
section.className = 'iz-section';
section.id = 'izFeedbackSection';
section.innerHTML = `
<div class="iz-section-title">📮 问题反馈</div>
<textarea id="izFeedbackText" placeholder="遇到问题或有建议?写在这里直接反馈~ " style="width:100%;box-sizing:border-box;resize:vertical;min-height:72px;background:#fff;color:#1E293B;border:1.5px solid #E2E8F0;border-radius:12px;padding:10px 12px;font-size:13px;font-family:inherit;line-height:1.6;outline:none;transition:border-color .2s,box-shadow .2s;"></textarea>
<div style="display:flex;align-items:center;justify-content:space-between;margin-top:10px;">
<span id="izFeedbackStatus" style="font-size:12px;color:#64748B;"></span>
<button id="izFeedbackBtn" style="padding:8px 20px;background:linear-gradient(135deg,#4F46E5,#7C3AED);color:#fff;border:none;border-radius:12px;font-size:13px;font-weight:600;cursor:pointer;transition:all .25s;box-shadow:0 4px 12px rgba(79,70,229,.3);">📮 提交反馈</button>
</div>`;
scroll.appendChild(section);
const textarea = section.querySelector('#izFeedbackText');
const status = section.querySelector('#izFeedbackStatus');
const btn = section.querySelector('#izFeedbackBtn');
textarea.addEventListener('focus', () => {
textarea.style.borderColor = '#4F46E5';
textarea.style.boxShadow = '0 0 0 3px rgba(79,70,229,.15)';
});
textarea.addEventListener('blur', () => {
textarea.style.borderColor = '#E2E8F0';
textarea.style.boxShadow = 'none';
});
btn.addEventListener('mouseenter', () => {
btn.style.transform = 'translateY(-2px)';
btn.style.boxShadow = '0 8px 24px rgba(79,70,229,.4)';
});
btn.addEventListener('mouseleave', () => {
btn.style.transform = '';
btn.style.boxShadow = '0 4px 12px rgba(79,70,229,.3)';
});
btn.addEventListener('click', () => {
const text = textarea.value.trim();
if (!text) {
status.textContent = '⚠️ 请先填写反馈内容';
status.style.color = '#F59E0B';
return;
}
btn.disabled = true;
btn.textContent = '提交中…';
btn.style.opacity = '0.7';
status.textContent = '';
submitFeedback(text)
.then(() => {
status.textContent = '✅ 反馈已提交,感谢你的支持!';
status.style.color = '#10B981';
textarea.value = '';
})
.catch((err) => {
status.textContent = '❌ ' + (err.message || '提交失败');
status.style.color = '#EF4444';
})
.finally(() => {
btn.disabled = false;
btn.textContent = '📮 提交反馈';
btn.style.opacity = '';
setTimeout(() => { status.textContent = ''; }, 4000);
});
});
}
function injectCustomRulesSection(overlay) {
const scroll = overlay.querySelector('.iz-panel-scroll');
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 '<div style="font-size:12px;color:#94A3B8;padding:6px 0;line-height:1.6;">暂无自定义规则。</div>';
return rules.map(r => `
<div class="iz-rule-item" data-id="${r.id}" style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid rgba(226,232,240,.5);">
<input type="checkbox" class="iz-rule-enabled" ${r.enabled ? 'checked' : ''} style="flex-shrink:0;">
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:#1E293B;">${escapeHtml(r.name)} <span style="font-size:11px;color:#94A3B8;font-weight:400;">· ${escapeHtml(r.domains)}</span></div>
<div style="font-size:11px;color:#64748B;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${r.imgMode === 'img' ? 'IMG' : '背景图'} | ${escapeHtml(r.itemSelector)} | ${escapeHtml(r.cardSelector || '无卡片选择器')}</div>
</div>
<button class="iz-rule-del" style="border:none;background:none;color:#EF4444;cursor:pointer;font-size:16px;flex-shrink:0;" title="删除">✕</button>
</div>`).join('');
};
section.innerHTML = `
<div class="iz-section-title">🎯 高级:站点规则自定义 <span class="iz-badge">进阶-不会添加用⬆️反馈</span></div>
<div class="iz-exclusion-note" style="margin-bottom:10px;">为当前网站添加悬停放大规则,解决遮罩层挡住鼠标、背景图无法放大等问题。保存后刷新页面生效。</div>
<div id="izRuleList">${renderList()}</div>
<button id="izRuleAddBtn" class="iz-btn-sm primary" style="margin-top:10px;">+ 为当前网站添加规则</button>
<div id="izRuleForm" style="display:none;margin-top:12px;padding:14px;background:#fff;border-radius:12px;border:1.5px solid #E2E8F0;">
<div class="iz-param-item" style="margin-bottom:10px;"><label>规则名称</label><input id="izRuleName" type="text" placeholder="选填" style="width:100%;box-sizing:border-box;height:36px;padding:0 10px;border:1.5px solid #E2E8F0;border-radius:10px;outline:none;font-size:13px;"></div>
<div class="iz-param-item" style="margin-bottom:10px;"><label>域名(逗号分隔,留空为当前网站)</label><input id="izRuleDomains" type="text" placeholder="${currentDomain}" style="width:100%;box-sizing:border-box;height:36px;padding:0 10px;border:1.5px solid #E2E8F0;border-radius:10px;outline:none;font-size:13px;"></div>
<div class="iz-param-item" style="margin-bottom:10px;"><label>图片容器选择器(背景图元素的 CSS 选择器)</label><input id="izRuleItem" type="text" placeholder="如:.image-container-top 或 .img-wrapper" style="width:100%;box-sizing:border-box;height:36px;padding:0 10px;border:1.5px solid #E2E8F0;border-radius:10px;outline:none;font-size:13px;"></div>
<div class="iz-param-item" style="margin-bottom:10px;"><label>卡片选择器</label><input id="izRuleCard" type="text" placeholder="如:.qtd-theme-card" style="width:100%;box-sizing:border-box;height:36px;padding:0 10px;border:1.5px solid #E2E8F0;border-radius:10px;outline:none;font-size:13px;"></div>
<div style="font-size:12px;color:#94A3B8;margin-bottom:10px;">💡 不会写选择器?点「🖱️ 拾取选择器」后直接在页面上点一下图片即可自动填写。</div>
<div style="display:flex;gap:10px;justify-content:flex-end;">
<button id="izRulePickBtn" class="iz-btn-sm" style="background:#EEF2FF;color:#4F46E5;border:none;">🖱️ 拾取选择器</button>
<button id="izRuleCancel" class="iz-btn-sm" style="background:#F1F5F9;color:#64748B;border:none;">取消</button>
<button id="izRuleSave" class="iz-btn-sm primary">保存规则</button>
</div>
</div>`;
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';
const shareBar = section.querySelector('.iz-rule-share-bar');
if (shareBar) shareBar.remove();
});
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 oldShareBar = section.querySelector('.iz-rule-share-bar');
if (oldShareBar) oldShareBar.remove();
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('规则已保存,刷新页面后生效');
const shareBar = document.createElement('div');
shareBar.className = 'iz-rule-share-bar';
shareBar.style.cssText = 'margin-top:10px;padding:10px 14px;background:#F0FDF4;border-radius:12px;display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;color:#166534;';
shareBar.innerHTML = `<span>是否把这条规则分享给作者,帮助更多用户?(仅发送域名和选择器)</span><button class="iz-rule-share-btn" style="flex-shrink:0;border:none;background:#10B981;color:#fff;padding:5px 14px;border-radius:8px;cursor:pointer;font-size:12px;font-weight:600;">分享</button>`;
form.parentNode.insertBefore(shareBar, form.nextSibling);
shareBar.querySelector('.iz-rule-share-btn').addEventListener('click', function() {
const btn = this;
btn.disabled = true;
btn.textContent = '发送中…';
submitSiteRule(lastSavedRule).then(() => {
btn.textContent = '✓ 已分享,感谢!';
btn.style.background = '#64748B';
storageSet(`rule_shared_${lastSavedRule.id}`, true);
setTimeout(() => shareBar.remove(), 3000);
}).catch((err) => {
btn.disabled = false;
btn.textContent = '重试';
showToast('分享失败:' + err.message);
});
});
});
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';
const renderParams = (defs) => defs.map(p => `
<div class="iz-param-item">
<label>${p.label}<span class="iz-tip-icon" data-tip="${p.tip}">?</span></label>
<div class="iz-input-group" data-param="${p.key}"><input type="number" class="iz-param-input" data-param="${p.key}" value="${config[p.key]}" min="${p.min}" max="${p.max}" step="${p.step}"/><span class="iz-unit">${p.unit}</span></div>
</div>`).join('');
overlay.innerHTML = `
<div id="izConfigPanel">
<div class="iz-panel-header">
<div class="iz-panel-header-left">
<div class="iz-panel-icon">🔍</div>
<div class="iz-panel-title">图片放大设置<span>· 悬停预览</span></div>
</div>
<button class="iz-close-btn" id="izCloseBtn" title="关闭 (ESC)">✕</button>
</div>
<div class="iz-panel-scroll">
<div class="iz-section">
<div class="iz-section-title">📌 主页排除 <span class="iz-badge">当前网站</span></div>
<div class="iz-exclusion-box">
<div class="iz-status-row">
<div class="iz-status-text"><span class="iz-dot on" id="izHpDot"></span><span id="izHpText">当前主页已启用图片放大</span></div>
<button class="iz-btn-sm warning" id="izHpToggleBtn">禁用主页图片放大功能</button>
</div>
<div class="iz-exclusion-note">仅对当前网站(${currentDomain})的主页生效,内容子页面不受影响,依然会放大图片</div>
</div>
</div>
<div class="iz-section">
<div class="iz-section-title">⚙️ 基本设置</div>
<div class="iz-row">
<div class="iz-row-label">放大模式<span class="iz-hint">智能 / 固定</span></div>
<div class="iz-row-control">
<select class="iz-select" id="izModeSelect">
<option value="adaptive">✨ 智能自适应</option>
<option value="fixed">📐 固定倍数</option>
</select>
</div>
</div>
<div class="iz-row">
<div class="iz-row-label" style="min-width:0;flex:1;">
<div class="iz-checkbox-wrap" id="izConflictWrap">
<div class="iz-checkbox-custom ${config.avoidClickConflict ? 'checked' : ''}" id="izConflictCheck"></div>
<span class="iz-checkbox-label">避免与点击放大功能冲突<span class="iz-sub">自动检测网站点击放大,避免冲突</span></span>
</div>
</div>
</div>
<div class="iz-row" style="margin-bottom:0">
<div class="iz-row-label" style="min-width:0;flex:1;">
<div class="iz-checkbox-wrap" id="izBlurDismissWrap">
<div class="iz-checkbox-custom ${config.blurDismiss ? 'checked' : ''}" id="izBlurDismissCheck"></div>
<span class="iz-checkbox-label">切换应用时自动收起放大图<span class="iz-sub">Alt+Tab 或点击其他窗口时关闭;关闭后切回可继续查看,适合对照场景</span></span>
</div>
</div>
</div>
</div>
<div class="iz-section">
<div class="iz-collapse-header" id="izCommonHeader">
<div class="iz-left"><span class="iz-arrow" id="izCommonArrow">▶</span><span>通用参数</span><span class="iz-badge-params">自适应 / 固定 都生效</span></div>
<span style="font-size:12px;color:#94A3B8;" id="izCommonHint">点击展开 · 悬停问号查看参数说明</span>
</div>
<div class="iz-collapse-body" id="izCommonBody"><div class="iz-param-grid">${renderParams(COMMON_PARAM_DEFS)}</div></div>
</div>
<div class="iz-section">
<div class="iz-collapse-header" id="izFixedHeader">
<div class="iz-left"><span class="iz-arrow" id="izFixedArrow">▶</span><span>固定倍数专用参数</span><span class="iz-badge-params" id="izModeBadge">智能自适应模式</span></div>
<span style="font-size:12px;color:#94A3B8;" id="izFixedHint">自适应模式下不可用</span>
</div>
<div class="iz-collapse-body" id="izFixedBody"><div class="iz-param-grid">${renderParams(FIXED_PARAM_DEFS)}</div></div>
</div>
<div class="iz-section">
<div class="iz-section-title">🎬 B站播放器辅助</div>
<div class="iz-row" style="margin-bottom:0">
<div class="iz-row-label" style="min-width:0;flex:1;">
<div class="iz-toggle-wrap" id="izBiliWrap">
<div class="iz-toggle ${bilibiliVolumeModule.isEnabled ? 'active' : ''}" id="izBiliToggle"><div class="iz-knob"></div></div>
<span class="iz-toggle-label">启用B站播放器辅助<span class="iz-sub">全屏时滚轮调节音量 · 方向键防穿透</span></span>
</div>
</div>
</div>
<div style="margin-top:10px;padding-left:2px;font-size:12px;line-height:1.7;color:#94A3B8;">
<div>· 放大模块会导致B站原生滚轮调整音量失效</div>
<div>· 需开启此辅助解决滚轮调整音量的问题</div>
</div>
</div>
</div>
<div class="iz-panel-footer">
<div style="display:flex;align-items:center;gap:8px;">
<button class="iz-btn-ghost" id="izHelpBtn">? 使用说明</button>
<button class="iz-btn-ghost" id="izResetBtn">↺ 恢复默认设置</button>
</div>
<button class="iz-btn-primary-solid" id="izSaveBtn">✓ 保存并关闭</button>
</div>
</div>`;
document.body.appendChild(overlay);
injectFeedbackSection(overlay);
injectCustomRulesSection(overlay);
const $ = (id) => overlay.querySelector('#' + id);
const modeSelect = $('izModeSelect');
const conflictCheck = $('izConflictCheck');
const commonHeader = $('izCommonHeader');
const commonBody = $('izCommonBody');
const commonArrow = $('izCommonArrow');
const commonHint = $('izCommonHint');
const fixedHeader = $('izFixedHeader');
const fixedBody = $('izFixedBody');
const fixedArrow = $('izFixedArrow');
const fixedHint = $('izFixedHint');
const modeBadge = $('izModeBadge');
const biliToggle = $('izBiliToggle');
// 参数说明气泡
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 updateDetailState() {
const isFixed = config.zoomMode === 'fixed';
modeBadge.textContent = isFixed ? '固定倍数模式' : '智能自适应模式';
if (isFixed) {
fixedHeader.style.cursor = 'pointer';
fixedHeader.style.opacity = '1';
fixedHint.textContent = '点击展开 · 悬停问号查看参数说明';
} else {
fixedBody.classList.remove('open');
fixedArrow.classList.remove('open');
fixedHeader.style.cursor = 'not-allowed';
fixedHeader.style.opacity = '0.55';
fixedHint.textContent = '自适应模式下不可用';
}
overlay.querySelectorAll('.iz-param-input').forEach(input => {
const isFixedParam = FIXED_PARAM_DEFS.some(p => p.key === input.dataset.param);
input.disabled = isFixedParam && !isFixed;
const group = input.closest('.iz-input-group');
if (group) group.classList.toggle('disabled-group', isFixedParam && !isFixed);
});
}
$('izHpToggleBtn').addEventListener('click', (e) => {
e.stopPropagation();
toggleHomepageDisabled();
});
modeSelect.addEventListener('change', () => {
config.zoomMode = modeSelect.value;
saveConfig();
updateDetailState();
showSaveToast(`已切换至 ${config.zoomMode === 'fixed' ? '固定倍数' : '智能自适应'} 模式`);
});
$('izConflictWrap').addEventListener('click', (e) => {
if (e.target.closest('.iz-checkbox-custom') || e.target.closest('.iz-checkbox-label')) {
config.avoidClickConflict = !config.avoidClickConflict;
conflictCheck.classList.toggle('checked', config.avoidClickConflict);
saveConfig();
showSaveToast(`避免与点击放大功能冲突 ${config.avoidClickConflict ? '已开启' : '已关闭'}`);
}
});
$('izBlurDismissWrap').addEventListener('click', (e) => {
if (e.target.closest('.iz-checkbox-custom') || e.target.closest('.iz-checkbox-label')) {
config.blurDismiss = !config.blurDismiss;
$('izBlurDismissCheck').classList.toggle('checked', config.blurDismiss);
saveConfig();
showSaveToast(`切换应用时收起放大图 ${config.blurDismiss ? '已开启' : '已关闭(切回可继续查看)'}`);
}
});
commonHeader.addEventListener('click', () => {
const isOpen = commonBody.classList.contains('open');
commonBody.classList.toggle('open');
commonArrow.classList.toggle('open');
commonHint.textContent = isOpen ? '点击展开 · 悬停问号查看参数说明' : '点击收起';
});
fixedHeader.addEventListener('click', () => {
if (config.zoomMode !== 'fixed') {
showSaveToast('切换到固定倍数模式后才能调整这些参数');
return;
}
const isOpen = fixedBody.classList.contains('open');
fixedBody.classList.toggle('open');
fixedArrow.classList.toggle('open');
fixedHint.textContent = isOpen ? '点击展开 · 悬停问号查看参数说明' : '点击收起';
});
overlay.querySelectorAll('.iz-param-input').forEach(input => {
const key = input.dataset.param;
const def = COMMON_PARAM_DEFS.concat(FIXED_PARAM_DEFS).find(p => p.key === key);
input.addEventListener('input', () => {
let val = parseFloat(input.value);
if (isNaN(val)) val = defaultConfig[key];
// 运行时输入按 CONFIG_LIMITS 钳制(之前只在加载时校验,
// 面板里手输 99999 会被原样保存进本次会话的 config)
if (CONFIG_LIMITS[key]) {
const [mn, mx] = CONFIG_LIMITS[key];
val = Math.max(mn, Math.min(mx, val));
}
config[key] = val;
saveConfig();
notifyConfigSaved(key, val, def ? def.label : key);
});
});
$('izBiliWrap').addEventListener('click', (e) => {
if (e.target.closest('.iz-toggle')) {
e.stopPropagation();
const newState = !bilibiliVolumeModule.isEnabled;
bilibiliVolumeModule.setEnabled(newState);
biliToggle.classList.toggle('active', newState);
showSaveToast(`B站播放器辅助 ${newState ? '已启用' : '已禁用'}`);
}
});
$('izResetBtn').addEventListener('click', () => {
if (!confirm('确定要恢复所有设置为默认值吗?')) return;
config = { ...defaultConfig };
saveConfig();
modeSelect.value = config.zoomMode;
conflictCheck.classList.toggle('checked', config.avoidClickConflict);
$('izBlurDismissCheck').classList.toggle('checked', config.blurDismiss);
overlay.querySelectorAll('.iz-param-input').forEach(input => {
input.value = config[input.dataset.param];
});
updateDetailState();
showToast('已恢复默认设置 🎉');
});
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();
closePanel();
setTimeout(() => showIntroPanel(true), 220);
});
$('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();
});
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;
}
overlay.querySelector('#izModeSelect').value = config.zoomMode;
overlay.querySelector('#izConflictCheck').classList.toggle('checked', config.avoidClickConflict);
overlay.querySelector('#izBlurDismissCheck').classList.toggle('checked', config.blurDismiss);
overlay.querySelectorAll('.iz-param-input').forEach(i => {
i.value = config[i.dataset.param];
});
overlay.querySelector('#izBiliToggle').classList.toggle('active', bilibiliVolumeModule.isEnabled);
refreshPanelHomepageSection();
overlay.classList.add('anim-in');
overlay.style.display = 'flex';
setTimeout(() => overlay.classList.remove('anim-in'), 400);
}
// ================
// 12. 主初始化
// ================
// ===== 颜色标记说明 =====
// 🟢 绿区:性能/结构优化区域,可优先修改
// 🟡 黄区:兼容性相关区域,修改后需重点测试网站
// 🔴 红区:hover放大核心链路,避免直接重构
function mainInit() {
loadConfig();
loadState();
injectStyles();
createDockButton();
if (isEnabled) {
setTimeout(showIntroPanel, 180);
setTimeout(showUpdateNotice, 260);
}
// ★ 全局 mousemove:坐标权威源(停稳裁决器/心跳/TIMER_FIRE 使用)。
// 直接写入不节流——handler 本身只有三次赋值,开销可忽略;
// 停稳裁决器通过 debounce 自身控制频率,无需在此节流。
document.addEventListener('mousemove', (e) => {
lastMouse.x = e.clientX;
lastMouse.y = e.clientY;
lastMouse.t = Date.now();
pointerInWindow = true; // ★ 光标回到浏览器内
}, { passive: true });
window.addEventListener('resize', debounce(() => {
if (isEnabled) zoomFSM.dispatch('DISMISS');
}, 250));
setupLightboxObserver();
if (isEnabled) initImages();
setupGlobalHoverStream(); // ★ mouseover 流 + 停稳裁决器 双保险
setupHeartbeat(); // ★ 持续复核(PENDING/ACTIVE,带失败容忍,后台页暂停)
setupBgRuleProxy();
setupAutoBackgroundHover();
startObserver();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mainInit);
} else {
mainInit();
}
})();