、、 等除外)。
*/
:is(#izModalOverlay,#izHelpModal,#izIntroPanel,#izUpdateNotice) :is(
b,strong,em,i,u,s,small,mark,code,time,cite,q,abbr,sub,sup,hr,
h1,h2,h3,h4,h5,h6,ul,ol,li,dl,dt,dd,blockquote,pre,figure,figcaption){
background-color:transparent!important;background-image:none!important;
box-shadow:none!important;text-shadow:none!important;
border-top:0!important;border-right:0!important;border-bottom:0!important;border-left:0!important
}
/* ② 自身有底色(或需 hover 底色)的元素:只补 transparent + !important,压过站点的 …!important。
特异性保持较低(0,1,x),让 .iz-adv>summary:hover(0,2,1)赢 → hover 反馈存活。 */
.izn-vsub,.izn-content summary,.iz-adv>summary{background-color:transparent!important;background-image:none!important}
/* ③ 短类名撞车防御:面板用了 .pn/.pd/.q/.a/.v/.d/.flex/.note/.ver 这类「无命名空间短类名」,
站点若有同名 class 规则就会给面板元素刷底色。
对策:复用原规则同款选择器 + !important((0,2,0)!important 压过站点 .pn 与 .pn{…!important});
刻意不扩大选择器,否则会误伤同族里有背景的规则。
维护:面板新增「无前缀短类名且不给背景」的元素,须追加到下一行的选择器列表。 */
.izn-place .pn,.izn-place .pd,.izn-mode .pn,.izn-mode .pd,
.izn-diag .q,.izn-diag .a,.izn-row .t,.izn-row .d,
.izn-hverline .v,.izn-hverline .d,
.izn-status .flex,.izn-lock .flex,.izn-status .note,.izn-hfoot .ver,
.izn-size .sz-name,.izn-sect-h .izn-hint,.izn-view.on,.iz-arrow.open
{background-color:transparent!important;background-image:none!important}
`;
document.head.appendChild(s);
dockStyleElement = s;
}
if (styleElement) return;
const style = document.createElement('style');
style.textContent = `
.image-zoom-container img{object-fit:contain}
#hoverWaitIndicator{position:fixed;left:0;top:0;width:20px;height:14px;transform:translate(-50%,-50%);z-index:2147483646;pointer-events:none;opacity:0;visibility:hidden;transition:opacity .14s ease,visibility .14s ease;filter:drop-shadow(0 1px 5px rgba(0,0,0,.28))}
#hoverWaitIndicator.show{opacity:1;visibility:visible}
#hoverWaitIndicator i{position:absolute;top:50%;width:4px;height:4px;margin-top:-2px;border-radius:50%;background:rgba(255,255,255,.98);box-shadow:0 0 6px rgba(125,211,252,.95),0 0 10px rgba(125,211,252,.38);animation:hoverWaitFocus 1s cubic-bezier(.45,0,.55,1) infinite}
#hoverWaitIndicator i:nth-child(1){left:1px;animation-delay:0s}
#hoverWaitIndicator i:nth-child(2){left:8px;width:5px;height:5px;margin-top:-2.5px;animation-delay:.14s}
#hoverWaitIndicator i:nth-child(3){left:15px;animation-delay:.28s}
@keyframes hoverWaitFocus{0%,100%{transform:translateY(1px) scale(.72);opacity:.42}38%{transform:translateY(-1px) scale(1.12);opacity:1}58%{transform:translateY(0) scale(.9);opacity:.82}}
/* 保留网页原本的鼠标光标,不由脚本覆盖 */
`;
document.head.appendChild(style);
styleElement = style;
}
// 全局样式注入
let toggleButton = null, gearButton = null, dockZone = null, dockTip = null, settingsTip = null;
function createDockButton() {
// 防御性去重:万一仍走到这里两次,先清掉旧的,避免出现两个控制球
const stale = document.getElementById('zoomDockZone');
if (stale && stale.parentNode) stale.parentNode.removeChild(stale);
dockZone = document.createElement('div');
dockZone.id = 'zoomDockZone';
toggleButton = document.createElement('div');
toggleButton.id = 'zoomDock';
toggleButton.innerHTML = ``;
toggleButton.title = '点击:切换图片放大';
gearButton = document.createElement('div');
gearButton.id = 'zoomSettings';
gearButton.innerHTML = ``;
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 = '主页已禁用图片放大 设置中可开启';
else if (!isEnabled) dockTip.innerHTML = '图片放大已关闭 点击开启';
else dockTip.innerHTML = '图片放大已开启 点击关闭';
}
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 悬浮控制区
// ----- 配置面板 -----
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();
// 面板若开着,总开关显示要跟着变(否则两处状态会不一致)
try {
const ov = document.getElementById('izModalOverlay');
if (ov && ov.__iznSync) ov.__iznSync();
} catch (err) { }
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';
}
}
// 弹层滚轮陷阱:面板/弹窗打开时滚轮只在弹层内可滚动区域生效,非滚动区或已达边界一律丢弃(防滚轮穿透滚走背后页面);
// 预览激活时直接放行。
function bindWheelTrap(el) {
if (!el || el.__izWheelTrap) return;
el.__izWheelTrap = true;
el.addEventListener('wheel', function (e) {
try {
if (zoomFSM && zoomFSM.hasActiveZoom && zoomFSM.hasActiveZoom()) return;
if (videoPreviewModule && videoPreviewModule.isActive && videoPreviewModule.isActive()) return;
} catch (err) { }
let node = e.target;
while (node && node !== el && node.nodeType === 1) {
const oy = getComputedStyle(node).overflowY;
if ((oy === 'auto' || oy === 'scroll' || oy === 'overlay') &&
node.scrollHeight > node.clientHeight + 1) {
const atTop = node.scrollTop <= 0;
const atBottom = node.scrollTop + node.clientHeight >= node.scrollHeight - 1;
// 内部还有可滚空间 → 正常滚,不拦
if ((e.deltaY > 0 && !atBottom) || (e.deltaY < 0 && !atTop)) return;
break; // 已到边界 → 拦掉,防止连锁滚到底层页面
}
node = node.parentElement;
}
try { e.preventDefault(); e.stopPropagation(); } catch (err) { }
}, { passive: false });
}
// ★ 首次使用说明(全局仅显示一次,不按域名保存)
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 = `
`;
document.body.appendChild(overlay);
bindWheelTrap(overlay);
const close = () => {
storageSet(INTRO_SEEN_KEY, true);
overlay.classList.add('anim-out');
setTimeout(() => {
overlay.style.display = 'none';
overlay.classList.remove('anim-out');
}, 300);
};
overlay.querySelector('#izIntroClose').addEventListener('click', close);
overlay.querySelector('#izIntroOk').addEventListener('click', close);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) close();
});
overlay._izClose = close;
}
if (!force && storageGet(INTRO_SEEN_KEY, false)) return;
// 自动首次显示时立即记为已展示;手动从设置打开时不改变全局已读状态。
if (!force) storageSet(INTRO_SEEN_KEY, true);
overlay.classList.remove('anim-out');
overlay.classList.add('anim-in');
overlay.style.display = 'flex';
setTimeout(() => overlay.classList.remove('anim-in'), 400);
}
// ★ 更新说明(全局按版本仅显示一次,不按域名保存)
const UPDATE_VERSION = (typeof SCRIPT_VERSION !== 'undefined' && SCRIPT_VERSION) ? SCRIPT_VERSION : '5.6.26';
const UPDATE_SEEN_KEY = `image_zoom_update_seen_${UPDATE_VERSION}`;
// ★ 使用说明 / 更新说明 弹窗(蓝色主题;内容与脚本实际行为一致)
function showHelpModal(tab) {
let mask = document.getElementById('izHelpModal');
if (!mask) {
mask = document.createElement('div');
mask.id = 'izHelpModal';
mask.innerHTML = `
基本玩法
- 鼠标悬停任意图片,停留片刻即放大预览;滚轮缩放(或上下平移),拖拽查看细节。
- 页面右下角的控制球随时开/关本站放大,也能打开设置面板。
- 面板里的设置只对当前网站生效——每个网站一套,互不干扰。
动作快捷键(预览显示时)
- s 保存高清图 · c 复制图片地址 · Shift+C 复制图片本体
- r 旋转 90° · Shift+R 水平翻转 · f 全屏 · 0 重置缩放
- 图集里:← → 翻页 · z 整组打包成 ZIP 下载 · Esc 关闭预览
看得更清楚
- 很多站的缩略图自带更大的原图,脚本会自动升级;升不了的站,用「图片规则 → 帮我找大图」点一下那张图试试。
- 想换放大后的观感与位置:去「显示与样式」调放大模式与显示位置。
图悬停没反应?
- 常见原因是页面遮罩挡住了鼠标,或图片是背景图——去「站点适配」点一下那张图,加条规则即可。
- 站首页不想启用:在「总览」开「主页不启用」,内容页不受影响。
${UPDATE_VERSION}${/test/i.test(UPDATE_VERSION) ? '测试版' : '正式版'}🔒 规则包已验签
本次更新:全新配置面板 · 视频悬停预览 · 图集翻页与打包 · 历史记录 / 键位自定义 · 三层换图规则;另含显示位置两模式与重采样分级。
新功能
- 全新配置面板:左侧 8 分区导航,图示化选择(放大模式 / 尺寸 / 位置 / 动画一眼对比),改动自动保存。
- 视频悬停预览:悬停页面视频或 B站 / YouTube 卡片即静音浮出播放,←/→ 快退快进。
- 图集翻页与打包:同一组图片 ←/→ 翻页,按 z 打包 ZIP;放大图下方显示尺寸、格式、来源与图说。
- 显示位置两模式:屏幕居中 / 原图周围不遮挡(「显示与样式」里切换)。
- 历史记录:最近看过的图可回看、打开原图、复制地址(只存本机)。
- 键位自定义:点键帽改绑,冲突自动让出;一键恢复默认。
- 配置备份与恢复:导出 / 导入 JSON,换设备或重装后一键还原。
规则与安全
- 三层换图规则:我的规则 → 云端规则包 → 内置兜底,命中即停;「点图选图」自动生成规则,遮罩层 / 背景图也能放大。
- 规则包签名校验:云端规则带签名与逐站哈希校验,被篡改即拒用。
- 网页版规则中心:ydgg123.github.io/hover-image-zoom/rules.html 可在线浏览全部站点规则。
- 一键提交规则到官方:规则导出后自动填好 Issue 内容。
修复与优化
- 低分辨率图高倍放大不再出现马赛克(重采样按倍率分级)。
- 「原图周围」模式下大图不遮挡原图;宽屏自适应放大不再偏小(默认上限跟随视口)。
- 旋转 / 翻转后外壳框跟随旋转;深层嵌套(如表格化)图集不再漏识别。
- 面板文字与选中态不再被站点样式染色;404 / 死链不再出现空白框。
- 防盗链图片自动绕过;智能高清升级按需选档,省流量。
`;
document.body.appendChild(mask);
bindWheelTrap(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.classList.remove('open'); });
mask.querySelector('.izn-hclose').addEventListener('click', () => mask.classList.remove('open'));
mask.querySelector('#izHelpOk').addEventListener('click', () => mask.classList.remove('open'));
mask.querySelector('#izHelpGoRules').addEventListener('click', () => { mask.classList.remove('open'); toggleConfigPanel(); });
mask.querySelectorAll('.izn-htabs button').forEach(b => b.addEventListener('click', () => {
mask.querySelectorAll('.izn-htabs button').forEach(x => x.classList.toggle('on', x === b));
mask.querySelectorAll('.izn-htab').forEach(p => p.classList.toggle('on', p.dataset.tab === b.dataset.tab));
}));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && mask.classList.contains('open')) mask.classList.remove('open'); });
}
mask.querySelectorAll('.izn-htabs button').forEach(x => x.classList.toggle('on', x.dataset.tab === tab));
mask.querySelectorAll('.izn-htab').forEach(p => p.classList.toggle('on', p.dataset.tab === tab));
mask.classList.add('open');
}
function showUpdateNotice() {
if (storageGet(UPDATE_SEEN_KEY, false)) return;
let notice = document.getElementById('izUpdateNotice');
if (!notice) {
notice = document.createElement('div');
notice.id = 'izUpdateNotice';
notice.innerHTML = `
🎛️ 新增:全新配置面板 —— 8 个分区、图示化选择(放大模式 / 尺寸 / 位置 / 动画一眼对比),改动自动保存。
▶️ 新增:视频悬停预览 —— 悬停 B站 / YouTube 卡片或页面视频即静音浮出播放,←/→ 快退快进。
🖼️ 新增:图集翻页与打包 —— ←/→ 翻页、按 z 打包 ZIP;放大图下方还会显示尺寸、格式、来源与图说。
🕘 新增:历史记录 + ⌨️ 键位自定义 —— 最近预览随时回看;13 个快捷键可视化改绑,可一键恢复默认。
💾 新增:配置备份与恢复 —— 导出 / 导入 JSON,换设备一键还原全部站点设置。
🐛 修复:404 空框、占位图误触发、旋转后外框不跟转、面板被站点样式染色、滚轮误关预览等一批问题。另:本次新增 GM_listValues 权限。
`;
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
// Config Panel UI module(保留原有配置面板代码,不改运行行为)
const COMMON_PARAM_DEFS = [
{ key: 'delay', label: '停留多久才弹出', unit: 'ms', min: 0, max: 2000, step: 100, tip: '越小越灵敏,越大越不容易误触发。', zeroText: '立刻弹出' },
{ key: 'minOriginalSize', label: '太小的图不放大', unit: 'px', min: 0, max: 500, step: 5, tip: '图标、表情、按钮一般小于 100px。', zeroText: '不过滤' },
{ key: 'maxWidth', label: '大图最大宽度', unit: 'px', min: 300, max: 3000, step: 100, tip: '图片始终保持原始比例。', markText: '跟随视口' },
{ key: 'maxHeight', label: '大图最大高度', unit: 'px', min: 300, max: 3000, step: 100, tip: '与最大宽度一起限制大图的尺寸。', markText: '跟随视口' },
{ key: 'scrollSpeed', label: '滚轮平移距离', unit: 'px', min: 5, max: 50, step: 1, tip: '内容超出屏幕时,滚轮每次移动的距离。' }
];
const FIXED_PARAM_DEFS = [
{ key: 'scale', label: '放大倍数', unit: '×', min: 1, max: 5, step: 0.1, tip: '相对原图的放大倍数。' },
{ key: 'minScale', label: '最小不小于', unit: '×', min: 1, max: 3, step: 0.1, tip: '再小也不会小于这个倍数。' },
{ key: 'smallImgThreshold', label: '多大的图算小图', unit: 'px', min: 100, max: 500, step: 10, tip: '显示尺寸小于这个值的,按小图处理。' },
{ key: 'smallImgWidth', label: '小图预览宽度', unit: 'px', min: 300, max: 1000, step: 10, tip: '小图放大后的宽度基准。' },
{ key: 'smallImgHeight', label: '小图预览高度', unit: 'px', min: 300, max: 1000, step: 10, tip: '小图放大后的高度基准。' }
];
// 「预览尺寸」四档:把 maxWidth/maxHeight 合并成一个直观选择,仍写回原字段(导出格式兼容);精确宽高在「高级」保留。
const SIZE_STEPS = [
{ w: 800, h: 600, name: '小' },
{ w: 1400, h: 1050, name: '中' },
{ w: 2200, h: 1650, name: '大' },
{ w: 3000, h: 3000, name: '跟随视口' }
];
function sizeStepIndex() {
for (let i = 0; i < SIZE_STEPS.length; i++) {
if (config.maxWidth === SIZE_STEPS[i].w && config.maxHeight === SIZE_STEPS[i].h) return i;
}
return -1; // 已用精确滑块自定义
}
// 档位 → 占「视口可用空间」的比例:小视口下若按 min(视口, 档位绝对尺寸) 取上限,中/大/跟随会撞同一上限失去区分。
const STEP_VIEWPORT_SCALE = [0.55, 0.72, 0.88, 1.0]; // 小 / 中 / 大 / 跟随视口
function stepViewportScale() {
const i = sizeStepIndex();
return i >= 0 ? STEP_VIEWPORT_SCALE[i] : 1.0; // 自定义滑块 → 不缩档(用绝对尺寸)
}
// 参数保存提示已由 showToast / showSaveToast 统一路由到面板中央
const notifyConfigSaved = debounce((key, value, label) => {
showSaveToast(`已保存:${label || key} = ${value}`);
}, 600);
function injectCustomRulesSection(overlay) {
const scroll = overlay.querySelector('#izSiteFitMount');
if (!scroll || scroll.querySelector('#izCustomRulesSection')) return;
const section = document.createElement('div');
section.className = 'iz-section';
section.id = 'izCustomRulesSection';
const renderList = () => {
const rules = getCustomRules();
if (!rules.length) return '暂无自定义规则。
';
return rules.map(r => `
`).join('');
};
section.innerHTML = `
🎯 高级:站点规则自定义 仅本地保存
为当前网站添加悬停放大规则,解决遮罩层挡住鼠标、背景图无法放大等问题。保存后刷新页面生效。
${renderList()}
`;
scroll.appendChild(section);
const listEl = section.querySelector('#izRuleList');
const form = section.querySelector('#izRuleForm');
let lastSavedRule = null;
section.querySelector('#izRuleAddBtn').addEventListener('click', () => {
form.style.display = 'block';
section.querySelector('#izRuleDomains').value = currentDomain;
});
// ===== 拾取模式(加固版:全屏遮罩拦截事件,站点收不到任何鼠标事件)=====
section.querySelector('#izRulePickBtn').addEventListener('click', () => {
form.style.display = 'block';
const ov0 = document.getElementById('izModalOverlay');
if (ov0) ov0.style.display = 'none';
const tips = document.createElement('div');
tips.style.cssText = 'position:fixed;z-index:2147483647;background:rgba(15,23,42,.92);color:#fff;padding:8px 12px;border-radius:8px;font:12px monospace;pointer-events:none;max-width:420px;display:none;white-space:nowrap;';
document.body.appendChild(tips);
const bar = document.createElement('div');
bar.style.cssText = 'position:fixed;top:12px;left:50%;transform:translateX(-50%);z-index:2147483647;background:#4F46E5;color:#fff;padding:8px 20px;border-radius:20px;font-size:13px;font-weight:600;';
bar.textContent = '🎯 点击图片附近任意位置(遮罩/标题也行),自动定位图片容器,按 ESC 取消';
document.body.appendChild(bar);
// ★ 全屏遮罩:所有鼠标事件落在遮罩上,站点自身的点击处理器(灯箱/加载层)不会被触发
const blocker = document.createElement('div');
blocker.style.cssText = 'position:fixed;inset:0;z-index:2147483646;cursor:crosshair;background:transparent;';
document.body.appendChild(blocker);
const shortSel = (el) => {
if (!el || el === document.body || el === document.documentElement) return null;
if (el.tagName === 'IMG') return 'img';
if (el.id) {
const s0 = '#' + CSS.escape(el.id);
if (document.querySelectorAll(s0).length <= 60) return s0;
}
for (const c of (el.classList || [])) {
const s0 = '.' + CSS.escape(c);
if (document.querySelectorAll(s0).length > 0) return s0;
}
return el.tagName.toLowerCase();
};
const deriveSelectors = (hitEl) => {
// 拾取器只负责"背景图"场景。★ IMG 元素不算背景图容器
let bgEl = null, node = hitEl;
while (node && node !== document.body) {
if (node.tagName === 'IMG') { node = node.parentElement; continue; }
try {
const bg = getComputedStyle(node).backgroundImage;
if (bg && bg !== 'none' && bg.includes('url(')) { bgEl = node; break; }
} catch (e) { }
if (node.querySelectorAll) {
const kids = node.querySelectorAll('*');
if (kids.length <= 800) {
for (const kid of kids) {
if (kid.tagName === 'IMG') continue;
try {
const bg2 = getComputedStyle(kid).backgroundImage;
if (bg2 && bg2 !== 'none' && bg2.includes('url(')) {
const kr = kid.getBoundingClientRect();
if (kr.width >= 80 && kr.height >= 80) { bgEl = kid; break; }
}
} catch (e) { }
}
if (bgEl) break;
}
}
node = node.parentElement;
}
if (!bgEl) return { imgMode: 'img' };
if (bgEl.tagName === 'IMG') return { imgMode: 'img' }; // 双保险
const br = bgEl.getBoundingClientRect();
if (br.width < 80 || br.height < 80) return { imgMode: 'img' };
const itemSel = shortSel(bgEl);
let card = null;
node = bgEl.parentElement;
while (node && node !== document.body) {
const r = node.getBoundingClientRect();
if (r.width < window.innerWidth * 0.9 && r.height < window.innerHeight * 0.9 &&
(node.querySelector('a') || node.querySelector('p'))) { card = node; break; }
node = node.parentElement;
}
let ok = false, count = 0;
try { count = document.querySelectorAll(itemSel).length; ok = count > 0; } catch (e) { }
return ok ? { itemSel, cardSel: card ? shortSel(card) : '', count, mode: 'background' } : { imgMode: 'img' };
};
const cleanupPicker = () => {
blocker.removeEventListener('mousemove', move, true);
blocker.removeEventListener('click', pick, true);
document.removeEventListener('keydown', esc, true);
blocker.remove();
tips.remove();
bar.remove();
};
const move = (e) => {
// 暂时藏起遮罩,让 elementFromPoint 穿透到页面真实元素
blocker.style.display = 'none';
const el = document.elementFromPoint(e.clientX, e.clientY);
blocker.style.display = 'block';
if (!el) return;
const d = deriveSelectors(el);
if (!d || d.imgMode === 'img') {
tips.style.display = 'block';
tips.style.left = Math.min(e.clientX + 16, window.innerWidth - 440) + 'px';
tips.style.top = (e.clientY + 16) + 'px';
tips.textContent = '✓ 普通图片无需自定义规则(脚本已自动支持遮罩盖图场景),请点击背景图类卡片';
tips.dataset.item = '';
tips.dataset.card = '';
return;
}
tips.style.display = 'block';
tips.style.left = Math.min(e.clientX + 16, window.innerWidth - 440) + 'px';
tips.style.top = (e.clientY + 16) + 'px';
tips.textContent = '图片容器: ' + d.itemSel + '(含图 ' + d.count + ' 张)' + (d.cardSel ? ' | 卡片: ' + d.cardSel : '');
tips.dataset.item = d.itemSel;
tips.dataset.card = d.cardSel || '';
};
const esc = (e) => { if (e.key === 'Escape') cleanupPicker(); };
const pick = (e) => {
const itemSel = tips.dataset.item, cardSel = tips.dataset.card;
cleanupPicker();
if (!itemSel) {
showToast('普通图片无需自定义规则,脚本已自动支持(仅背景图卡片需要规则)');
const ov = document.getElementById('izModalOverlay');
if (ov) {
ov.classList.add('anim-in');
ov.style.display = 'flex';
setTimeout(() => ov.classList.remove('anim-in'), 400);
}
return;
}
section.querySelector('#izRuleItem').value = itemSel;
section.querySelector('#izRuleCard').value = cardSel;
showSaveToast('已拾取:' + itemSel + ',确认后保存');
const ov = document.getElementById('izModalOverlay');
if (ov) {
ov.classList.add('anim-in');
ov.style.display = 'flex';
setTimeout(() => ov.classList.remove('anim-in'), 400);
}
};
document.addEventListener('keydown', esc, true);
blocker.addEventListener('mousemove', move, true);
blocker.addEventListener('click', pick, true);
});
section.querySelector('#izRuleCancel').addEventListener('click', () => {
form.style.display = 'none';
});
section.querySelector('#izRuleSave').addEventListener('click', () => {
const itemSelector = section.querySelector('#izRuleItem').value.trim();
if (!itemSelector) { showToast('请填写图片容器选择器'); return; }
try { document.querySelector(itemSelector); } catch (e) { showToast('选择器语法有误,请检查'); return; }
// 只允许背景图规则:校验目标元素(或其子元素)有 background-image
let isBgSel = false;
try {
const els = document.querySelectorAll(itemSelector);
for (const el of els) {
if ((getComputedStyle(el).backgroundImage || '').includes('url(')) { isBgSel = true; break; }
if (el.querySelector && [...el.querySelectorAll('*')].slice(0, 50).some(k => (getComputedStyle(k).backgroundImage || '').includes('url('))) {
isBgSel = true;
break;
}
}
} catch (e) { }
if (!isBgSel) {
showToast('该选择器未匹配到背景图元素。普通图片无需规则(脚本已自动支持)');
return;
}
const rules = getCustomRules();
const newRule = {
id: 'r' + Date.now(),
name: section.querySelector('#izRuleName').value.trim() || currentDomain,
domains: section.querySelector('#izRuleDomains').value.trim() || currentDomain,
imgMode: 'background',
itemSelector,
cardSelector: section.querySelector('#izRuleCard').value.trim(),
pollInterval: 300,
enabled: true
};
rules.push(newRule);
saveCustomRules(rules);
lastSavedRule = newRule;
listEl.innerHTML = renderList();
form.style.display = 'none';
showSaveToast('规则已保存,刷新页面后生效');
});
listEl.addEventListener('click', (e) => {
const item = e.target.closest('.iz-rule-item');
if (!item) return;
const rules = getCustomRules();
const idx = rules.findIndex(r => r.id === item.dataset.id);
if (idx < 0) return;
if (e.target.classList.contains('iz-rule-del')) {
rules.splice(idx, 1);
saveCustomRules(rules);
listEl.innerHTML = renderList();
showSaveToast('规则已删除');
} else if (e.target.classList.contains('iz-rule-enabled')) {
rules[idx].enabled = e.target.checked;
saveCustomRules(rules);
}
});
}
function createConfigPanel() {
const overlay = document.createElement('div');
overlay.id = 'izModalOverlay';
// ★ 数值参数一律「滑块 + 数字框」:
// 滑块负责探索(区间可见、可拖着看效果),数字框负责精确输入(保持既有能力不丢)。
// zeroText / markText 用于在滑杆两端标出语义(如 0 = 不过滤、3000 = 跟随视口)。
const sliderFill = (v, min, max) => {
const p = max > min ? Math.max(0, Math.min(100, ((v - min) / (max - min)) * 100)) : 0;
return p.toFixed(2) + '%';
};
const renderParams = (defs) => defs.map(p => {
const v = config[p.key];
return `
`;
}).join('');
// ★ 分段控件:2–4 个选项时替代下拉框(选项一眼看全、一次点中)
// cur 可显式指定选中值(用于「预览尺寸」这种不直接对应 config 字段的派生状态)
const renderSeg = (key, opts, cur) => {
const active = (cur === undefined) ? config[key] : cur;
return `
${opts.map(o => ``).join('')}
`;
};
// ★ 预览尺寸档位:每档画一个「视口框 + 按比例缩放的方块」,
// 方块大小直接表达「大图能有多大」,比四个文字按钮直观得多。
// (用字符串拼接而非模板字符串,避免与外层 innerHTML 的反引号冲突)
const sizeStepHtml = SIZE_STEPS.map((s, i) => {
// 用「宽度比例」而非 min(宽比,高比):预览区高度总是宽度的 0.6–0.8 倍,
// 取 min 会让方块尺寸实际由高度决定,四档被压成 27/40/60/100,差异不明显。
// 改用宽度比例后是 33/53/80/100,档位差距一眼可辨。
const pct = Math.round((s.w / 3000) * 100);
const bw = Math.max(28, pct);
const bh = Math.max(26, Math.round(pct * 0.85));
const on = sizeStepIndex() === i ? ' on' : '';
return '';
}).join('');
// 通用参数按语义拆分到不同分区(每项仍是同一个 config 字段)
const DISPLAY_SIZE_DEFS = COMMON_PARAM_DEFS.filter(p => p.key === 'maxWidth' || p.key === 'maxHeight');
const TRIGGER_BASIC_DEFS = COMMON_PARAM_DEFS.filter(p => p.key === 'delay' || p.key === 'minOriginalSize');
const SCROLL_DEFS = COMMON_PARAM_DEFS.filter(p => p.key === 'scrollSpeed');
overlay.innerHTML = `
当前网站 ${currentDomain}
改动已自动保存
总览与诊断
先看这里:本站是否生效、规则命中情况,以及最常见的几个问题该去哪里调。
当前网站状态只作用于 ${currentDomain}
启用图片放大
关闭后本网站的悬停放大与视频预览全部停用(其它网站不受影响)。等效于点击页面上的控制球。
站首页通常图片密集、容易误触发;开启后只关首页,内容子页面照常生效。
规则与数据概况
我的规则
0 条云端规则包与本站命中情况见「图片规则」
云端规则包
读取中🔒 签名校验状态与更新入口在「图片规则」
遇到问题?点一下直达对应设置
显示与样式
决定大图「多大、出现在哪、长什么样」。
放大模式方块宽度 = 大图宽度
大图能有多大方块大小就是大图大小
${sizeStepHtml}
高级:分别设置宽高
${renderParams(DISPLAY_SIZE_DEFS)}
大图出现在哪看动画对比
出现方式看动画对比
系统开启「减少动态效果」时会自动降级为直接淡入。
外观
显示图片信息栏
在大图下方显示尺寸、格式、来源和图说。
触发与交互
什么时候弹出、怎么操作它,以及和页面点击放大的相处方式。
触发时机
${renderParams(TRIGGER_BASIC_DEFS)}
避开「点一下才放大」的网站
自动识别这类站点,避免重复触发。
切到别的应用时自动收起
关掉后,切换应用时预览会保留。
滚轮与视频
${renderParams(SCROLL_DEFS)}
悬停视频时预览播放
悬停视频卡片时静音浮出播放,按 Esc 关闭。
B站全屏音量修正
B站全屏时如果滚轮调不了音量,开启这项即可。
⚠︎
当前是智能自适应模式,下面 5 项不生效。
${renderParams(FIXED_PARAM_DEFS)}
键位
只在预览显示时生效。键位对所有网站通用,改一次到处都是这套键。
换图规则
从上到下依次生效:① 我的规则 → ② 云端规则包 → ③ 内置兜底。
🔍 帮我找大图
图片看不清?点「点图选图」后,直接在网页上点那张图就行。
1我的规则 优先
这里加的规则会盖过云端和内置规则。
高级:手写匹配规则 / 导出
2
云端规则包
读取中
按站点下发,不能直接改;某个站不合适,就用上面的「我的规则」盖住它。
规则包只提供「正则 → 替换」的数据,不执行任何代码。
站点适配
页面用遮罩挡住鼠标、或图片是背景图时,在这里把真正的图片容器告诉脚本。
历史记录
最近悬停看过的图片,最多 60 条,只保存在本机。
`;
document.body.appendChild(overlay);
bindWheelTrap(overlay);
injectCustomRulesSection(overlay);
const $ = (id) => overlay.querySelector('#' + id);
const conflictToggle = $('izConflictToggle');
const modeBadge = $('izModeBadge');
const biliToggle = $('izBiliToggle');
// 快捷开关 / 折叠区:键盘可操作 + 无障碍状态同步
overlay.querySelectorAll('.iz-switch-card').forEach(wrap => {
const tg = wrap.querySelector('.iz-toggle');
if (!tg) return;
const syncSw = () => wrap.setAttribute('aria-checked', tg.classList.contains('active') ? 'true' : 'false');
new MutationObserver(syncSw).observe(tg, { attributes: true, attributeFilter: ['class'] });
syncSw();
wrap.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); wrap.click(); }
});
});
const wireCollapseA11y = (header, body) => {
const syncExp = () => header.setAttribute('aria-expanded', body.classList.contains('open') ? 'true' : 'false');
new MutationObserver(syncExp).observe(body, { attributes: true, attributeFilter: ['class'] });
syncExp();
header.addEventListener('keydown', (e) => {
if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); header.click(); }
});
};
// 规则包:原独立折叠区块已并入「图片规则」区的 ② 云端规则包,元素 id 保持不变,状态渲染与按钮绑定无需改动。
function renderPackStatus() {
const badge = $('izPackBadge'), st = $('izPackStatus'), note = $('izPackNote');
if (!badge || !st || !note) return;
const meta = rulePackState.meta || {};
const cur = rulePackState.current;
if (cur && cur.version) {
badge.textContent = 'v' + cur.version;
st.textContent = '已加载规则包 v' + cur.version + (meta.at ? '(' + String(meta.at).slice(0, 10) + ')' : '');
} else {
badge.textContent = '内置';
st.textContent = '当前使用内置规则';
}
const hits = ((cur && cur.domains) || []).filter(function (d) { return packDomainMatches(currentDomain, d.domain); });
const extra = hits.length
? ' 本站命中:' + hits.map(function (d) { return d.label || d.domain; }).join('、')
: ' 本站无专属规则';
// 签名校验状态(供应链加固):verified 只在「验签+哈希全过」时写入 meta
let sig = '';
if (meta.verified) sig = ' 🔒 签名校验通过';
else if (meta.lastError && /^sig-|^hash-/.test(meta.lastError)) sig = ' ⚠ 签名校验未通过(' + meta.lastError + ')';
const err = meta.lastError ? ' 上次更新失败:' + meta.lastError : '';
note.textContent = '规则包只提供「正则 → 替换」的数据,不执行任何代码。' + sig + extra + err;
}
renderPackStatus();
const packAutoWrap = $('izPackAutoWrap');
if (packAutoWrap) {
packAutoWrap.addEventListener('click', () => {
config.rulePackAuto = !config.rulePackAuto;
$('izPackAutoToggle').classList.toggle('active', config.rulePackAuto);
packAutoWrap.setAttribute('aria-checked', config.rulePackAuto ? 'true' : 'false');
saveConfig();
});
}
const packUpdateBtn = $('izPackUpdateBtn');
if (packUpdateBtn) {
packUpdateBtn.addEventListener('click', () => {
packUpdateBtn.disabled = true;
packUpdateBtn.textContent = '更新中…';
updateRulePack(true).then(function (r) {
packUpdateBtn.disabled = false;
packUpdateBtn.textContent = '↻ 立即更新';
renderPackStatus();
if (r && r.ok) showToast(r.unchanged ? '已是最新规则包 v' + r.version : '规则包已更新到 v' + r.version + ' 🎉');
else if (r && r.reason === 'busy') showToast('规则包正在更新中,请稍候再试');
else showToast('更新失败:' + ((r && r.reason) || '未知') + '(继续使用现有规则)');
});
});
}
const packRollbackBtn = $('izPackRollbackBtn');
if (packRollbackBtn) {
packRollbackBtn.addEventListener('click', () => {
const r = rollbackRulePack();
renderPackStatus();
showToast(r.ok ? '已回滚到 v' + r.version : '没有可回滚的版本');
});
}
// ===== 图片地址规则(极简版:一键找大图;正则收进「高级」)=====
const urHeader = $('izUrlRuleHeader'), urBody = $('izUrlRuleBody');
if (urHeader && urBody) {
wireCollapseA11y(urHeader, urBody);
urHeader.addEventListener('click', () => {
const isOpen = urBody.classList.contains('open');
urBody.classList.toggle('open');
$('izUrlRuleArrow').classList.toggle('open');
$('izUrlRuleHint').innerHTML = isOpen ? '点开编辑' : '点击收起';
if (!isOpen) renderUrlRuleStatus(); // 展开时刷新「最近命中」等状态
});
}
function renderUrlRules() {
const list = $('izUrlRuleList');
if (!list) return;
const rules = (config.userUrlRules || []);
$('izUrlRuleCount').textContent = rules.length ? ('· ' + rules.length + ' 条') : '';
if (!rules.length) {
list.innerHTML = '还没有。用上面的「帮我找大图」就能加一条,不用懂任何技术。
';
return;
}
list.innerHTML = rules.map(function (r, i) {
return ''
+ '
'
+ '
' + escapeHtml(r.label || ('规则 ' + (i + 1))) + '
'
+ '
' + (r.enabled !== false ? '生效中' : '已停用') + ' · ' + (r.phase === 'clean' ? '清理' : '找大图') + ' · ' + (r.scope === 'global' ? '所有网站' : ('仅 ' + escapeHtml(r.domain || '本站'))) + '
'
+ '
'
+ '
';
}).join('');
}
function commitUrlRules() { saveConfig(); rebuildEffectiveRules(); renderUrlRules(); renderUrlRuleStatus(); }
// 新规则的生效范围:默认「只在当前网站」(记下 domain),可选「所有网站」
function segValue(segId, fallback) {
const box = $(segId);
if (!box) return fallback;
const on = box.querySelector('button.on');
return on ? on.dataset.val : fallback;
}
function readRuleScope() {
if (segValue('izUrlScope', 'site') === 'global') return { scope: 'global', domain: '' };
return { scope: 'site', domain: currentDomain };
}
renderUrlRules();
renderUrlRuleStatus(); // 新布局默认展开规则区,打开面板即显示命中状态
// 「规则在当前网站生效了吗」——生效规则数、来源构成、最近一次真实命中
function renderUrlRuleStatus() {
const st = $('izUrlRuleStatus'), note = $('izUrlRuleNote');
if (!st || !note) return;
const userN = (config.userUrlRules || []).filter(function (r) { return r.enabled !== false; }).length;
const pack = rulePackState.current;
const hits = ((pack && pack.domains) || []).filter(function (d) { return packDomainMatches(currentDomain, d.domain); });
const packN = hits.reduce(function (a, d) { return a + ((d.hd || []).length + (d.clean || []).length); }, 0);
const builtinN = URL_RULES.length + HD_UPGRADE_RULES.length;
// 顶部折叠头计数 + ③ 内置兜底条数(合并成单一「图片规则」区后新增)
const cntEl = $('izRuleCount');
if (cntEl) cntEl.textContent = (userN + packN + builtinN) + ' 条生效';
const biEl = $('izBuiltinCount');
if (biEl) biEl.textContent = builtinN + ' 条';
st.textContent = currentDomain + ':生效 ' + (userN + packN + builtinN) + ' 条规则'
+ '(① 我的 ' + userN + ' / ② 云端 ' + packN + ' / ③ 内置 ' + builtinN + ')';
const parts = [];
if (lastRuleHit) {
const f = String(lastRuleHit.from), t = String(lastRuleHit.to);
const tail = function (s) { return s.length > 68 ? '…' + s.slice(-68) : s; };
parts.push('✅ 最近一次实际命中【' + (lastRuleHit.name || lastRuleHit.id) + '】 '
+ tail(f) + ' → ' + tail(t));
} else {
parts.push('⏳ 本次页面还没命中过规则:悬停一张图片后回到这里看结果。');
}
if (hits.length) parts.push('规则包为本站提供:' + hits.map(function (d) { return d.label || d.domain; }).join('、'));
else parts.push('规则包暂无本站专属规则(使用内置规则)。');
note.textContent = parts.join(' | ');
}
renderUrlRuleStatus();
// ===== 历史记录 =====
function fmtHistTime(ts) {
if (!ts) return '';
const d = new Date(ts), now = new Date();
const pad = n => (n < 10 ? '0' + n : '' + n);
const hm = pad(d.getHours()) + ':' + pad(d.getMinutes());
if (d.toDateString() === now.toDateString()) return '今天 ' + hm;
return (d.getMonth() + 1) + '-' + d.getDate() + ' ' + hm;
}
function renderHistory() {
const list = $('izHistoryList');
if (!list) return;
const items = getHistory();
const cnt = $('izHistoryCount');
if (cnt) cnt.textContent = items.length ? ('· ' + items.length + ' 条') : '';
if (!items.length) {
list.innerHTML = '还没有。悬停放大过的图片会自动记在这里(只保存在本机,最多 60 条)。
';
return;
}
list.innerHTML = items.map(function (r) {
const dim = (r.w > 0 && r.h > 0) ? (r.w + '×' + r.h) : '';
const meta = [r.host, dim, fmtHistTime(r.ts)].filter(Boolean).join(' · ');
const thumb = r.thumb || r.u;
return ''
+ '
 + ')
'
+ '
' + escapeHtml(r.u) + '
'
+ '
' + escapeHtml(meta) + '
'
+ '
'
+ ''
+ ''
+ ''
+ '
';
}).join('');
}
const histHeader = $('izHistoryHeader'), histBody = $('izHistoryBody');
if (histHeader && histBody) {
wireCollapseA11y(histHeader, histBody);
histHeader.addEventListener('click', () => {
const isOpen = histBody.classList.contains('open');
histBody.classList.toggle('open');
$('izHistoryArrow').classList.toggle('open');
$('izHistoryHint').innerHTML = isOpen ? '点开查看' : '点击收起';
if (!isOpen) renderHistory(); // 展开时刷新(悬停可能新增了记录)
});
const histClear = $('izHistoryClearBtn');
if (histClear) histClear.addEventListener('click', () => {
clearHistory();
renderHistory();
iznSync(); // ★ 角标(导航栏历史条数)也要跟着归零,否则列表空了角标还挂着旧数
showSaveToast('历史记录已清空');
});
const histList = $('izHistoryList');
if (histList) histList.addEventListener('click', (e) => {
const item = e.target.closest('[data-hid]');
if (!item) return;
const hid = item.getAttribute('data-hid');
const actEl = e.target.closest('[data-act]');
const act = actEl ? actEl.getAttribute('data-act') : '';
const entry = getHistory().find(x => x && x.id === hid);
if (act === 'del') { removeHistoryItem(hid); renderHistory(); iznSync(); }
else if (act === 'open' && entry && typeof GM_openInTab === 'function') {
try { GM_openInTab(entry.u, { active: true }); } catch (err) { showSaveToast('打开失败'); }
}
else if (act === 'copy' && entry && typeof GM_setClipboard === 'function') {
GM_setClipboard(entry.u, 'text');
showSaveToast('已复制图片地址');
}
});
}
renderHistory();
// ★ 一键找大图:把所有已知变换在这个地址上各试一遍,真实加载后按大小给用户挑。
// 用户完全不需要懂正则——看到的缩略图就是最终效果。
let autoCands = [];
function loadImgForTest(url) {
return new Promise(function (res) {
const t = setTimeout(function () { res(null); }, 8000);
const im = new Image();
im.onload = function () { clearTimeout(t); res({ w: im.naturalWidth, h: im.naturalHeight }); };
im.onerror = function () { clearTimeout(t); res(null); };
im.src = url;
});
}
async function autoFindBig(url) {
const out = $('izUrlAutoOut');
try {
out.innerHTML = '正在尝试各种方式…
';
const orig = await loadImgForTest(url);
if (!orig) {
out.innerHTML = '这个地址打不开。请确认复制的是「图片地址」(右键图片 → 复制图片地址)。
';
return;
}
const cands = []; const seen = new Set();
activeHdRules.forEach(function (r) {
(r.steps || []).forEach(function (s) {
// ★ 兼容两种步长格式:内置 [regex, replace](2 元素)/ 规则包 [pattern, flags, replace](3 元素)
const is3 = s.length > 2;
const pat = s[0], fl = is3 ? (s[1] || '') : '', rep = is3 ? s[2] : s[1];
let re = (pat instanceof RegExp) ? pat : new RegExp(pat, fl);
let c; try { c = url.replace(re, rep); } catch (e) { return; }
if (c && c !== url && !seen.has(c)) { seen.add(c); cands.push({ rule: { label: r.name || '自动优化', phase: 'hd', pattern: pat, flags: fl, replace: rep }, url: c }); }
});
});
if (!cands.length) {
const dbg = [];
activeHdRules.forEach(function (r) {
let chg = false;
(r.steps || []).forEach(function (s) {
const is3 = s.length > 2;
const pat = s[0], fl = is3 ? (s[1] || '') : '', rep = is3 ? s[2] : s[1];
try { const re = (pat instanceof RegExp) ? pat : new RegExp(pat, fl); const t = url.replace(re, rep); if (t !== url) chg = true; } catch (e) { chg = 'ERR'; }
});
dbg.push((r.id || '?') + (chg === true ? '✓' : (chg === 'ERR' ? '✗err' : '·')));
});
out.innerHTML = '暂时没有已知的优化方式适用于这个地址。'
+ '
(诊断:' + dbg.join(',') + ')
';
return;
}
const results = await Promise.all(cands.map(function (c) {
return loadImgForTest(c.url).then(function (r) { return r ? { c: c, w: r.w, h: r.h } : null; });
}));
// 按位置对应:加载成功的带尺寸,失败的标「未验证」(不能靠 slice 尾部——失败项散布在 results 里)
const loaded = results.map(function (r, i) { return r ? { c: cands[i], w: r.w, h: r.h } : null; }).filter(Boolean);
const unverified = results.map(function (r, i) { return r ? null : { c: cands[i], w: 0, h: 0 }; }).filter(Boolean);
autoCands = loaded.concat(unverified);
if (!autoCands.length) {
out.innerHTML = '这个地址没有可尝试的变换。
';
return;
}
autoCands.sort(function (a, b) { return (b.w * b.h) - (a.w * a.h); }); // 未验证(w=0)排最后
const rowHtml = function (r, i) {
return ''
+ '
 + ')
'
+ '
' + (r.w ? (r.w + ' × ' + r.h) : '未验证') + '
'
+ '
' + escapeHtml(r.c.url.slice(-72)) + '
'
+ '
';
};
const biggerIdx = [], smallerIdx = [], unverIdx = [];
autoCands.forEach(function (r, i) {
if (!r.w) unverIdx.push(i);
else if (r.w * r.h > orig.w * orig.h) biggerIdx.push(i);
else smallerIdx.push(i);
});
let html = '';
if (biggerIdx.length) {
html += '找到 ' + biggerIdx.length + ' 个更大的版本(原来 ' + orig.w + '×' + orig.h + '),点一个就用:
'
+ biggerIdx.map(function (i) { return rowHtml(autoCands[i], i); }).join('');
} else {
html += '没有比原图(' + orig.w + '×' + orig.h + ')更大的版本。
';
}
if (smallerIdx.length) {
html += '另有 ' + smallerIdx.length + ' 种方式,但没比原图大
'
+ '' + smallerIdx.map(function (i) { return rowHtml(autoCands[i], i); }).join('') + '
';
}
if (unverIdx.length) {
html += '未验证(加载失败,可手动试)' + unverIdx.length + ' 个
'
+ '' + unverIdx.map(function (i) { return rowHtml(autoCands[i], i); }).join('') + '
';
}
out.innerHTML = html;
} catch (e) {
out.innerHTML = '出错了:' + escapeHtml(String(e && e.message || e)) + '
';
}
}
$('izUrlAutoBtn').addEventListener('click', function () {
const v = $('izUrlAutoIn').value.trim();
if (!v) { showSaveToast('请先粘贴图片地址'); return; }
autoFindBig(v);
});
// ★ 点图选图:关面板 → 用户直接点网页图片 → 自动找大图(不用复制粘贴)
function extractPickUrl(t) {
let el = t, hops = 0;
while (el && el !== document.body && hops < 4) {
if (el.tagName === 'IMG') { const u = el.currentSrc || el.src || ''; if (u) return u; }
try {
const bg = getComputedStyle(el).backgroundImage;
if (bg && bg !== 'none') { const m = bg.match(/url\((['"]?)(.+?)\1\)/); if (m && m[2] && m[2].indexOf('data:') !== 0) return m[2]; }
} catch (e) { }
el = el.parentElement; hops++;
}
return '';
}
let pickBar = null, pickClick = null, pickKeydown = null, pickStyle = null;
function closePickUi() {
urlPickMode = false;
document.documentElement.classList.remove('hv-picking');
if (pickClick) { window.removeEventListener('click', pickClick, true); pickClick = null; }
if (pickKeydown) { window.removeEventListener('keydown', pickKeydown, true); pickKeydown = null; }
if (pickBar) { pickBar.remove(); pickBar = null; }
if (pickStyle) { pickStyle.remove(); pickStyle = null; }
}
function reopenPanelAfterPick() {
const ov = document.getElementById('izModalOverlay');
if (!ov || ov.style.display !== 'flex') toggleConfigPanel();
const body = document.getElementById('izUrlRuleBody');
if (body && !body.classList.contains('open')) { const h = document.getElementById('izUrlRuleHeader'); if (h) h.click(); }
setTimeout(function () { const b = document.getElementById('izUrlAutoIn'); if (b) b.scrollIntoView({ block: 'center' }); }, 80);
}
$('izUrlPickBtn').addEventListener('click', function () {
if (urlPickMode) return;
urlPickMode = true;
try { zoomFSM.dispatch('DISMISS'); } catch (e) { } // 收起可能开着的预览
const ov = document.getElementById('izModalOverlay');
if (ov && ov.style.display === 'flex') ov.style.display = 'none'; // 面板让位,别挡图
pickStyle = document.createElement('style');
pickStyle.textContent = 'html.hv-picking, html.hv-picking *{cursor:crosshair!important}';
document.documentElement.appendChild(pickStyle);
document.documentElement.classList.add('hv-picking');
pickBar = document.createElement('div');
pickBar.style.cssText = 'position:fixed;left:50%;top:18px;transform:translateX(-50%);z-index:2147483000;background:rgba(15,23,42,.92);color:#fff;padding:10px 20px;border-radius:12px;font-size:13px;font-family:Arial,sans-serif;box-shadow:0 8px 28px rgba(0,0,0,.35);pointer-events:none;white-space:nowrap;';
pickBar.textContent = '🖱 点击网页上想看大图的图片(Esc 取消)';
document.body.appendChild(pickBar);
pickKeydown = function (e) {
if (e.key !== 'Escape') return;
e.stopImmediatePropagation();
closePickUi();
reopenPanelAfterPick();
showSaveToast('已取消选图');
};
window.addEventListener('keydown', pickKeydown, true);
pickClick = function (e) {
// 自有 UI 不算选图(面板此时已关,防误触 dock/预览层)
if (e.target && e.target.closest && e.target.closest('#zoomDockZone, #izModalOverlay, #izIntroOverlay, #izUpdateNotice, #izHelpModal, .image-zoom-container')) return;
const url = extractPickUrl(e.target);
e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); // 别点进链接 / 别触发页面行为
closePickUi();
if (url) {
$('izUrlAutoIn').value = url;
reopenPanelAfterPick();
autoFindBig(url);
} else {
reopenPanelAfterPick();
showSaveToast('刚才点的不是图片,再点「点图选图」选一张网页图片');
}
};
window.addEventListener('click', pickClick, true);
});
$('izUrlAutoOut').addEventListener('click', (e) => {
const b = (e.target && e.target.closest) ? e.target.closest('[data-autoidx]') : null;
if (!b) return;
const cand = autoCands[Number(b.dataset.autoidx)];
if (!cand || !cand.c) return;
const ruleSrc = cand.c.rule;
const patStr = (ruleSrc.pattern instanceof RegExp) ? ruleSrc.pattern.source : String(ruleSrc.pattern || '');
config.userUrlRules = normalizeUserUrlRules((config.userUrlRules || []).concat([
Object.assign({ phase: 'hd', enabled: true }, ruleSrc, { id: 'u' + Date.now(), label: (cand.w ? ('大图 ' + cand.w + '×' + cand.h) : '自动找的大图规则'), enabled: true, pattern: patStr }, readRuleScope())
]));
commitUrlRules();
showSaveToast('已保存!本站这类图片以后自动换大图 🎉');
$('izUrlAutoOut').innerHTML = '';
$('izUrlAutoIn').value = '';
});
// 高级:手写正则(保留给会写的人)
$('izUrlRuleSave').addEventListener('click', () => {
const pattern = $('izUrlPattern').value.trim();
if (!pattern) { showSaveToast('正则 pattern 不能为空'); return; }
const flags = $('izUrlFlags').value.trim().replace(/[^gimsuy]/g, '');
try { new RegExp(pattern, flags); } catch (e) { showSaveToast('正则不合法:' + e.message); return; }
config.userUrlRules = normalizeUserUrlRules((config.userUrlRules || []).concat([
Object.assign({
id: 'u' + Date.now(),
label: $('izUrlLabel').value.trim(),
phase: segValue('izUrlPhase', 'hd') === 'clean' ? 'clean' : 'hd',
pattern: pattern, flags: flags, replace: $('izUrlReplace').value, enabled: true
}, readRuleScope())
]));
['izUrlPattern', 'izUrlFlags', 'izUrlReplace', 'izUrlLabel'].forEach(function (id) { $(id).value = ''; });
commitUrlRules();
showSaveToast('规则已保存并立即生效');
});
$('izUrlRuleList').addEventListener('click', (e) => {
const item = (e.target && e.target.closest) ? e.target.closest('.iz-urlrule-item') : null;
if (!item) return;
const id = item.dataset.id;
if (e.target.classList.contains('iz-urlrule-enabled')) {
config.userUrlRules = (config.userUrlRules || []).map(function (r) {
return r.id === id ? Object.assign({}, r, { enabled: e.target.checked }) : r;
});
commitUrlRules();
} else if (e.target.classList.contains('iz-urlrule-del')) {
config.userUrlRules = (config.userUrlRules || []).filter(function (r) { return r.id !== id; });
commitUrlRules();
showSaveToast('已删除该规则');
}
});
// 贡献闭环:把当前站点的自定义规则导出为「可直接放进 rules/ 目录」的片段
function buildRuleSnippet() {
// 只导出「在当前站确实生效」的规则,避免把别的站的规则当成本站提交
const rules = (config.userUrlRules || []).filter(function (r) {
if (r.enabled === false) return false;
if (r.scope === 'site' && r.domain && !packDomainMatches(currentDomain, r.domain)) return false;
return true;
});
if (!rules.length) return null;
return JSON.stringify({
domain: currentDomain,
label: currentDomain,
note: '由用户在面板中导出;建议先在本站确认有效再提交',
hd: rules.filter(function (r) { return r.phase !== 'clean'; }).map(function (r, i) {
return { id: 'u' + (i + 1), name: r.label || '原图还原', loop: false, steps: [[r.pattern, r.flags, r.replace]] };
}),
clean: rules.filter(function (r) { return r.phase === 'clean'; }).map(function (r, i) {
return { id: 'c' + (i + 1), name: r.label || '地址清理', loop: false, steps: [[r.pattern, r.flags, r.replace]] };
})
}, null, 1);
}
$('izUrlRuleShare').addEventListener('click', () => {
const snippet = buildRuleSnippet();
if (!snippet) { showSaveToast('还没有可导出的自定义规则'); return; }
copyText(snippet).then(function (ok) {
showSaveToast(ok ? '规则片段已复制,可粘贴到 rules/ 目录或提交 Issue' : '复制失败,请手动复制');
});
});
// 一键提交:打开 GitHub 新建 Issue 页面,标题与正文已自动填好(同时把片段复制到剪贴板兜底)
$('izUrlRuleFeedback').addEventListener('click', () => {
const snippet = buildRuleSnippet();
if (!snippet) { showSaveToast('先在本站加一条规则(用「帮我找大图」)再提交'); return; }
try { copyText(snippet); } catch (e) { }
const title = '[规则] ' + currentDomain + ' 大图还原';
const body = [
'### 站点', currentDomain, '',
'### 规则片段(面板导出,已在本站验证)', '```json', snippet, '```', '',
'### 环境',
'- 悬景版本:' + SCRIPT_VERSION,
'- 浏览器:' + navigator.userAgent, '',
'> 由面板「一键提交到官方」生成;提交前请确认规则在本站确实有效。'
].join('\n');
const url = 'https://github.com/YDGG123/hover-image-zoom/issues/new'
+ '?title=' + encodeURIComponent(title)
+ '&body=' + encodeURIComponent(body);
// 优先用 GM_openInTab(脚本沙箱里 window.open 常被拦截)
let opened = false;
try {
if (typeof GM_openInTab === 'function') { GM_openInTab(url, { active: true, insert: true }); opened = true; }
} catch (e) { opened = false; }
if (!opened) { try { opened = !!window.open(url, '_blank'); } catch (e) { opened = false; } }
showSaveToast(opened ? '已打开 GitHub 提交页(内容已填好,规则片段也已复制)' : '未能自动打开:规则片段已复制,请手动打开 GitHub Issue');
});
// 参数说明气泡
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 ? '以下 5 项参数参与计算' : '这些设置只在「固定倍数」模式下生效';
const lock = $('iznFixedLock'), card = $('iznFixedCard');
if (lock) lock.hidden = isFixed;
if (card) card.classList.toggle('izn-locked', !isFixed);
overlay.querySelectorAll('.iz-slider-row').forEach(row => {
const off = FIXED_PARAM_DEFS.some(p => p.key === row.dataset.param) && !isFixed;
row.querySelectorAll('input').forEach(i => { i.disabled = off; });
row.classList.toggle('disabled-group', off);
});
}
// ===== 数值参数:滑块 + 数字框 =====
// 滑块拖动时只改内存 + 界面(避免高频写存储),松手(change)才落盘并提示;
// 数字框保留精确输入能力,回车或失焦生效。两者始终双向同步。
function paintSlider(sl) {
const mn = parseFloat(sl.min), mx = parseFloat(sl.max), v = parseFloat(sl.value);
const p = mx > mn ? Math.max(0, Math.min(100, ((v - mn) / (mx - mn)) * 100)) : 0;
sl.style.setProperty('--fill', p.toFixed(2) + '%');
}
function markModified(key, val) {
const item = overlay.querySelector('.iz-param-item[data-mod-key="' + key + '"]');
if (item) item.classList.toggle('mod', Number(val) !== Number(defaultConfig[key]));
}
function syncParamControls(key, val, except) {
overlay.querySelectorAll('[data-param="' + key + '"]').forEach(el => {
if (el.tagName !== 'INPUT' || el === except) return; // 只同步输入控件(slider-row 容器也带 data-param)
if (String(el.value) !== String(val)) el.value = val;
});
overlay.querySelectorAll('.iz-slider[data-param="' + key + '"]').forEach(paintSlider);
markModified(key, val);
}
function commitParam(key, val) {
const def = COMMON_PARAM_DEFS.concat(FIXED_PARAM_DEFS).find(p => p.key === key);
if (isNaN(val)) val = defaultConfig[key];
// 把值对齐到「滑块步进」网格,保证数字框与滑块取值一致:步进取 min(定义步进, 1),
// 整数参数可用任意整数、小数参数保持 0.1 精度。
if (def && def.step) {
const step = Math.min(Number(def.step) || 1, 1);
const base = (typeof def.min === 'number') ? def.min : 0;
val = Math.round((val - base) / step) * step + base;
val = Math.round(val * 1000) / 1000;
}
if (CONFIG_LIMITS[key]) {
const lim = CONFIG_LIMITS[key];
val = Math.max(lim[0], Math.min(lim[1], val));
}
config[key] = val;
saveConfig();
syncParamControls(key, val);
notifyConfigSaved(key, val, def ? def.label : key);
}
overlay.querySelectorAll('.iz-slider').forEach(sl => {
const key = sl.dataset.param;
paintSlider(sl);
sl.addEventListener('input', () => {
paintSlider(sl);
config[key] = parseFloat(sl.value);
syncParamControls(key, sl.value, sl);
});
sl.addEventListener('change', () => {
commitParam(key, parseFloat(sl.value));
const num = overlay.querySelector('.iz-param-num[data-param="' + key + '"]');
if (num) { num.classList.remove('pop'); void num.offsetWidth; num.classList.add('pop'); }
if (key === 'maxWidth' || key === 'maxHeight') syncSizeStep();
});
});
overlay.querySelectorAll('.iz-param-num').forEach(num => {
const key = num.dataset.param;
const apply = () => {
commitParam(key, parseFloat(num.value));
if (key === 'maxWidth' || key === 'maxHeight') syncSizeStep();
};
num.addEventListener('change', apply);
num.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); num.blur(); } });
});
// ===== 分段控件(替代下拉框)=====
function syncSeg(key, val) {
const seg = overlay.querySelector('.iz-seg[data-seg="' + key + '"]');
if (!seg) return;
seg.querySelectorAll('button').forEach(b => b.classList.toggle('on', String(b.dataset.val) === String(val)));
}
// ★ 放大模式:两张动画大卡(不再是下拉/分段,因为这是决定全局行为的关键选项)
function syncZoomModes() {
const box = $('iznZoomModes');
if (!box) return;
const cur = config.zoomMode === 'fixed' ? 'fixed' : 'adaptive';
box.querySelectorAll('.izn-mode').forEach(b => b.classList.toggle('on', b.dataset.mode === cur));
}
const zoomModesBox = $('iznZoomModes');
if (zoomModesBox) {
zoomModesBox.addEventListener('click', (e) => {
const btn = (e.target && e.target.closest) ? e.target.closest('.izn-mode') : null;
if (!btn) return;
config.zoomMode = btn.dataset.mode === 'fixed' ? 'fixed' : 'adaptive';
saveConfig();
syncZoomModes();
updateDetailState();
showSaveToast('已切到 ' + (config.zoomMode === 'fixed' ? '固定倍数' : '智能自适应') + ' 模式');
});
}
// ★ 预览尺寸:4 档方块(方块大小 = 大图大小)
function syncSizeStep() {
const box = $('iznSizeSteps');
if (!box) return;
const idx = sizeStepIndex();
box.querySelectorAll('.izn-size').forEach(b => b.classList.toggle('on', String(b.dataset.val) === String(idx)));
}
const sizeStepsBox = $('iznSizeSteps');
if (sizeStepsBox) {
sizeStepsBox.addEventListener('click', (e) => {
const btn = (e.target && e.target.closest) ? e.target.closest('.izn-size') : null;
if (!btn) return;
const s = SIZE_STEPS[parseInt(btn.dataset.val, 10)];
if (!s) return;
config.maxWidth = s.w;
config.maxHeight = s.h;
saveConfig();
syncParamControls('maxWidth', s.w);
syncParamControls('maxHeight', s.h);
syncSizeStep();
showSaveToast('预览尺寸:' + s.name);
});
}
// 规则区两个分段控件(urlScope / urlPhase)只作为「下一条规则」的默认值,
// 由 readRuleScope 与保存时读取,无需落盘 —— 这里只负责切换选中态。
overlay.querySelectorAll('.iz-seg[data-seg]').forEach(seg => {
seg.addEventListener('click', (e) => {
const btn = (e.target && e.target.closest) ? e.target.closest('button[data-val]') : null;
if (!btn || !seg.contains(btn)) return;
seg.querySelectorAll('button').forEach(b => b.classList.toggle('on', b === btn));
});
});
$('izHpToggleBtn').addEventListener('click', (e) => {
e.stopPropagation();
toggleHomepageDisabled();
});
// 显示位置(center/around)两卡片
function syncPlaces() {
const cur = config.previewPlacement || 'center';
overlay.querySelectorAll('#iznPlaces .izn-place').forEach(p => p.classList.toggle('on', p.dataset.place === cur));
}
syncPlaces();
overlay.__iznSyncPlaces = syncPlaces;
overlay.querySelectorAll('#iznPlaces .izn-place').forEach(p => p.addEventListener('click', () => {
config.previewPlacement = p.dataset.place;
saveConfig();
syncPlaces();
const names = { center: '屏幕居中', around: '原图周围(不遮挡)' };
showSaveToast(`显示位置:${names[config.previewPlacement]}(下次预览生效)`);
}));
// 出现方式(dock/spotlight/fade)三卡片
function syncTransitions() {
const cur = config.previewTransition || 'dock';
overlay.querySelectorAll('#iznTransitions .izn-place').forEach(p => p.classList.toggle('on', p.dataset.fx === cur));
}
syncTransitions();
overlay.__iznSyncTransitions = syncTransitions;
overlay.querySelectorAll('#iznTransitions .izn-place').forEach(p => p.addEventListener('click', () => {
config.previewTransition = p.dataset.fx;
saveConfig();
syncTransitions();
const names = { dock: '从原图弹出', spotlight: '从原图绽开', fade: '直接淡入' };
showSaveToast('出现方式:' + (names[config.previewTransition] || config.previewTransition) + '(下次预览生效)');
}));
// ===== izn 导航 / 总览同步 / 总开关 =====
const iznContent = $('iznContent');
function iznGo(view) {
overlay.querySelectorAll('.izn-nav-item').forEach(x => x.classList.toggle('on', x.dataset.view === view));
overlay.querySelectorAll('.izn-view').forEach(v => v.classList.toggle('on', v.dataset.view === view));
if (iznContent) iznContent.scrollTop = 0;
}
overlay.querySelectorAll('.izn-nav-item').forEach(x => x.addEventListener('click', () => iznGo(x.dataset.view)));
overlay.querySelectorAll('[data-izn-go]').forEach(b => b.addEventListener('click', () => iznGo(b.dataset.iznGo)));
function iznSync() {
try {
const master = $('iznMasterWrap');
if (master) {
master.setAttribute('aria-checked', String(isEnabled));
const tg = master.querySelector('.iz-toggle');
if (tg) tg.classList.toggle('active', isEnabled);
}
const rs = $('iznRuleSummary');
if (rs) rs.textContent = (config.userUrlRules || []).filter(r => r.enabled !== false).length + ' 条';
const ps = $('iznPackSummary');
if (ps) { const m = (typeof rulePackState !== 'undefined' && rulePackState.meta) || {}; ps.textContent = m.version ? ('v' + m.version) : '未加载'; }
const ht = $('iznHistTag');
if (ht) { try { const h = storageGet('hvHistoryV1', []); ht.textContent = Array.isArray(h) ? h.length : 0; } catch (e) { } }
} catch (e) { }
}
overlay.__iznGo = iznGo; overlay.__iznSync = iznSync;
// 面板控件「单一同步入口」:把每个控件校正为「当前 config + isEnabled + 哔哩开关」的真实状态;
// 「打开面板」与「恢复默认」都走这里。
function syncPanelFromState() {
try { syncZoomModes(); } catch (e) { }
try { conflictToggle.classList.toggle('active', config.avoidClickConflict); } catch (e) { }
const bt = $('izBlurDismissToggle'); if (bt) bt.classList.toggle('active', config.blurDismiss);
const wt = $('izWheelZoomToggle'); if (wt) wt.classList.toggle('active', config.wheelZoom);
const it = $('izImageInfoToggle'); if (it) it.classList.toggle('active', config.showImageInfo);
const vt = $('izVideoHoverToggle'); if (vt) vt.classList.toggle('active', config.videoHoverPreview);
const bl = $('izBiliToggle'); if (bl) bl.classList.toggle('active', bilibiliVolumeModule.isEnabled);
// 数值参数:滑块与数字框一起校正,并重绘填充色与「已改」标记
COMMON_PARAM_DEFS.concat(FIXED_PARAM_DEFS).forEach(p => syncParamControls(p.key, config[p.key]));
syncPlaces(); // 显示位置卡片选中态
syncTransitions(); // 出现方式卡片选中态
syncSizeStep(); // 预览尺寸档位选中态
// ★ 历史列表条目也要一起刷新:悬停预览会持续写入历史,而 renderHistory 原先只在
// 面板「构建时 / 折叠展开时 / 删除时」调用 → 重开面板看到的是陈旧列表
// (角标 iznHistTag 却由 iznSync 更新过,于是出现「角标 4 条、列表 0 条」的错位)。
renderHistory();
iznSync(); // 总开关 aria-checked + 规则/规则包/历史条数角标
}
overlay.__izSyncAll = syncPanelFromState;
// 总开关:isEnabled(image_zoom_enabled_<域名>),等效控制球。
// 用 overlay 捕获阶段委托(点击目标无论被内部结构如何包裹都能命中)。
overlay.addEventListener('click', (e) => {
const wrap = (e.target && e.target.closest) ? e.target.closest('#iznMasterWrap') : null;
if (!wrap) return;
e.stopPropagation();
isEnabled = !isEnabled;
storageSet('image_zoom_enabled_' + currentDomain, isEnabled);
wrap.setAttribute('aria-checked', String(isEnabled));
const tg = $('iznMasterToggle');
if (tg) tg.classList.toggle('active', isEnabled);
try { updateButtonState(); } catch (err) { } // 同步 dock 球的颜色/状态点
showSaveToast(isEnabled ? '已启用本网站图片放大' : '已停用本网站图片放大');
if (!isEnabled) { try { zoomFSM.dispatch('RESET'); } catch (err) { } }
}, true);
$('izConflictWrap').addEventListener('click', (e) => {
e.stopPropagation();
config.avoidClickConflict = !config.avoidClickConflict;
conflictToggle.classList.toggle('active', config.avoidClickConflict);
saveConfig();
showSaveToast(`避免与点击放大功能冲突 ${config.avoidClickConflict ? '已开启' : '已关闭'}`);
});
$('izBlurDismissWrap').addEventListener('click', (e) => {
e.stopPropagation();
config.blurDismiss = !config.blurDismiss;
$('izBlurDismissToggle').classList.toggle('active', config.blurDismiss);
saveConfig();
showSaveToast(`窗口失焦时收起放大图 ${config.blurDismiss ? '已开启' : '已关闭(切换应用时保留预览)'}`);
});
$('izWheelZoomWrap').addEventListener('click', (e) => {
e.stopPropagation();
config.wheelZoom = !config.wheelZoom;
$('izWheelZoomToggle').classList.toggle('active', config.wheelZoom);
saveConfig();
showSaveToast(`滚轮控制放大图缩放 ${config.wheelZoom ? '已开启' : '已关闭(恢复上下移动)'}`);
});
$('izVideoHoverWrap').addEventListener('click', (e) => {
e.stopPropagation();
config.videoHoverPreview = !config.videoHoverPreview;
$('izVideoHoverToggle').classList.toggle('active', config.videoHoverPreview);
saveConfig();
if (!config.videoHoverPreview) videoPreviewModule.hide();
showSaveToast(`视频悬停预览 ${config.videoHoverPreview ? '已开启' : '已关闭'}`);
});
$('izImageInfoWrap').addEventListener('click', (e) => {
e.stopPropagation();
config.showImageInfo = !config.showImageInfo;
$('izImageInfoToggle').classList.toggle('active', config.showImageInfo);
saveConfig();
// 关闭时若正有放大图在显示,顺手把已渲染的浮层摘掉
if (!config.showImageInfo) {
const live = document.querySelector('#izImageInfo');
if (live) live.remove();
const liveBar = document.querySelector('.hv-capbar');
if (liveBar) liveBar.remove();
}
showSaveToast(`图片信息栏 ${config.showImageInfo ? '已开启' : '已关闭'}`);
});
// 键位区:与其它折叠区完全一致的交互
const keymapHeader = $('izKeymapHeader');
const keymapBody = $('izKeymapBody');
const keymapArrow = $('izKeymapArrow');
const keymapHint = $('izKeymapHint');
if (keymapHeader && keymapBody) {
wireCollapseA11y(keymapHeader, keymapBody);
keymapHeader.addEventListener('click', () => {
const isOpen = keymapBody.classList.contains('open');
keymapBody.classList.toggle('open');
keymapArrow.classList.toggle('open');
keymapHint.innerHTML = isOpen ? '点右侧按键即可改绑' : '点击收起';
});
}
$('iznToFixedBtn').addEventListener('click', () => {
config.zoomMode = 'fixed';
saveConfig();
syncZoomModes();
updateDetailState();
showSaveToast('已切到固定倍数模式');
});
$('izBiliWrap').addEventListener('click', (e) => {
e.stopPropagation();
const newState = !bilibiliVolumeModule.isEnabled;
bilibiliVolumeModule.setEnabled(newState);
biliToggle.classList.toggle('active', newState);
showSaveToast(`B站播放器辅助 ${newState ? '已启用' : '已禁用'}`);
});
// ===== 键位:可视化改绑 =====
const keymapGrid = $('izKeymapGrid');
let keyRecording = null;
config.keymap = normalizeKeymap(config.keymap); // 兜底,避免旧配置缺字段
function keyCapText(action) {
const list = (config.keymap && config.keymap[action]) || [];
return list.length ? list.map(displayKeyName).join(' / ') : '未设置';
}
function renderKeymap() {
if (!keymapGrid) return;
keymapGrid.innerHTML = KEYMAP_ACTION_DEFS.map(function (d) {
return '' + escapeHtml(d.name) + ''
+ escapeHtml(d.hint) + '
';
}).join('');
}
function stopKeyRecord() {
if (!keyRecording) return;
window.removeEventListener('keydown', onKeyRecord, true);
keyRecording = null;
}
// 录制:捕获阶段拦截,避免被面板自身的 Esc/其它快捷键吃掉
function onKeyRecord(e) {
if (!keyRecording) return;
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') { stopKeyRecord(); renderKeymap(); return; }
if (['Control', 'Shift', 'Alt', 'Meta', 'CapsLock', 'Dead', 'Unidentified'].indexOf(e.key) >= 0) return;
const k = e.key;
// 一个键只归一个动作:先从其它动作里摘掉,避免同一个键绑两处
// (若该键原本绑在别的动作上,提示去向,用户不会疑惑「原来的键怎么失效了」)
const stolen = [];
Object.keys(config.keymap).forEach(function (a) {
if (a !== keyRecording) {
const before = (config.keymap[a] || []).length;
config.keymap[a] = (config.keymap[a] || []).filter(function (x) { return x !== k; });
if (config.keymap[a].length < before) stolen.push(a);
}
});
config.keymap[keyRecording] = [k];
// 让出后若原动作变空(「未设置」),回退到它的默认键(前提:该默认键未被任何动作占用)。
const usedKeys = new Set();
Object.keys(config.keymap).forEach(function (a) {
(config.keymap[a] || []).forEach(function (x) { usedKeys.add(x); });
});
const restored = [];
stolen.forEach(function (a) {
if ((config.keymap[a] || []).length) return;
const free = (KEYMAP_DEFAULTS[a] || []).filter(function (x) { return !usedKeys.has(x); });
if (!free.length) return;
config.keymap[a] = free.slice();
free.forEach(function (x) { usedKeys.add(x); });
restored.push(a);
});
saveConfig();
saveGlobalKeymap(config.keymap); // ★ 键位全局:同步写入全局键
stopKeyRecord();
renderKeymap();
if (stolen.length) {
const nameOf = function (a) {
const d = KEYMAP_ACTION_DEFS.find(function (x) { return x.key === a; });
return d ? d.name : a;
};
let msg = '「' + k + '」已从「' + nameOf(stolen[0]) + '」移到当前动作';
if (restored.length) {
msg += ';「' + nameOf(restored[0]) + '」已恢复默认键 '
+ (KEYMAP_DEFAULTS[restored[0]] || []).map(displayKeyName).join(' / ');
} else {
// 默认键也已被占用 → 无法自动回退;明确告知「去哪恢复」,避免用户不知原动作为何失效
const lost = stolen.filter(function (a) { return (config.keymap[a] || []).length === 0; });
if (lost.length) {
msg += ';「' + nameOf(lost[0]) + '」已失去快捷键,点它的键帽可重新设置';
}
}
showSaveToast(msg);
}
}
if (keymapGrid) {
renderKeymap();
keymapGrid.addEventListener('click', function (e) {
const btn = (e.target && e.target.closest) ? e.target.closest('.iz-key-cap') : null;
if (!btn) return;
stopKeyRecord();
keyRecording = btn.dataset.km;
btn.classList.add('recording');
btn.textContent = '按下按键…';
window.addEventListener('keydown', onKeyRecord, true);
});
// 恢复默认键位(只动键表,不动其它设置)
const kmReset = $('izKeymapResetBtn');
if (kmReset) kmReset.addEventListener('click', function () {
if (!confirm('把所有按键恢复为默认键位?')) return;
config.keymap = normalizeKeymap(null);
saveConfig();
saveGlobalKeymap(config.keymap); // ★ 键位全局:默认键位也要落全局键
stopKeyRecord();
renderKeymap();
showSaveToast('已恢复默认键位');
});
}
$('izResetBtn').addEventListener('click', () => {
if (!confirm('确定恢复当前网站的默认设置吗?\n其它网站不受影响;全局键位也会保留。')) return;
// ★ 键位表是全局配置(跨网站共享),不随「恢复本站默认」一起重置 ——
// 否则用户在某站误点一次重置,所有网站的自定义键位都会丢。
const keepKeymap = normalizeKeymap(config.keymap);
config = { ...defaultConfig };
config.keymap = keepKeymap;
saveConfig();
// 总开关(isEnabled / image_zoom_enabled_<域名>)独立持久化、不在 defaultConfig 里:
// 恢复默认时须四处一起同步(存储 / 内存变量 / 面板开关 / dock 球),漏一处就状态不一致。
isEnabled = true;
storageSet('image_zoom_enabled_' + currentDomain, true);
try { updateButtonState(); } catch (e) { } // 同步 dock 球的颜色/状态点
syncPanelFromState(); // 面板全部控件(含总开关/出现方式/视频悬停/显示位置)
updateDetailState();
renderKeymap(); // 键位区跟随(全局键位未被改动,这里只是重绘)
showToast('已恢复本站默认设置');
});
$('izExportBtn').addEventListener('click', (e) => {
e.stopPropagation();
exportConfigToFile();
});
const izImportInput = $('izImportInput');
$('izImportBtn').addEventListener('click', (e) => {
e.stopPropagation();
izImportInput.click();
});
izImportInput.addEventListener('click', (e) => e.stopPropagation());
izImportInput.addEventListener('change', (e) => {
const file = e.target.files && e.target.files[0];
importConfigFromFile(file, (res) => {
// 面板是照打开时的 config 渲染的,逐项同步容易漏;整块重建最稳,
// 「站点规则自定义」区也会按导入后的规则重新渲染。
const msg = '已导入 ' + res.ok + ' 项配置' + (res.skipped ? ',跳过 ' + res.skipped + ' 项' : '') + ' ✅';
closePanel();
// 提示必须等面板重建完成后再显示:showToast 在面板开着时会挂进 #izConfigPanel,而这里紧接着 remove 整个 overlay → 提示会随之消失。
setTimeout(() => { overlay.remove(); toggleConfigPanel(); showToast(msg); }, 320);
});
e.target.value = ''; // 清空以便连续导入同一个文件
});
function closePanel() {
overlay.classList.add('anim-out');
setTimeout(() => {
overlay.style.display = 'none';
overlay.classList.remove('anim-out');
}, 300);
}
$('izCloseBtn').addEventListener('click', closePanel);
$('izHelpBtn').addEventListener('click', (e) => { e.stopPropagation(); showHelpModal('usage'); });
$('izChangelogBtn').addEventListener('click', (e) => { e.stopPropagation(); showHelpModal('changelog'); });
// 反馈问题:自动附小尺寸截图(若有预览过图片)+ 预填环境信息的 GitHub Issue
$('izFeedbackBtn').addEventListener('click', (e) => {
e.stopPropagation();
// 1) 截图落盘(本地,不上传任何服务器;GitHub 提交时用户自行拖入)
let shotName = '';
if (lastPreviewShotData) {
shotName = 'hv-feedback-screenshot.png';
try {
if (typeof GM_download === 'function') {
GM_download({ url: lastPreviewShotData, name: shotName, saveAs: false, onerror: function () { } });
} else {
downloadViaAnchor(lastPreviewShotData, shotName);
}
} catch (err) { shotName = ''; }
}
// 2) 预填 Issue:环境信息 + 截图指引
let ver = '', handler = '';
try {
if (typeof GM_info !== 'undefined') {
ver = GM_info.script && GM_info.script.version;
handler = (GM_info.scriptHandler || '') + (GM_info.version ? ' ' + GM_info.version : '');
}
} catch (err) { }
const body = [
'### 问题描述', '', '(请在这里描述遇到的问题)', '',
'### 复现步骤', '', '1. ', '2. ', '',
'### 环境信息',
'- 脚本版本: ' + (ver || '未知'),
'- 脚本管理器: ' + (handler || '未知'),
'- 出问题的页面: ' + location.href,
'- User-Agent: ' + navigator.userAgent, '',
'### 附件',
shotName
? '已自动保存截图 `' + shotName + '`(在浏览器下载目录),请把该图片拖进上面的编辑框一并提交。'
: '未能自动截图(最近没有预览过图片或图片受跨域保护),可自行截图后拖进编辑框。'
].join('\n');
const url = 'https://github.com/YDGG123/hover-image-zoom/issues/new'
+ '?title=' + encodeURIComponent('[反馈] ')
+ '&body=' + encodeURIComponent(body);
try { GM_openInTab(url, { active: true }); } catch (err) { window.open(url, '_blank'); }
showSaveToast(shotName ? '已保存截图并打开反馈页' : '已打开反馈页');
});
$('izSaveBtn').addEventListener('click', () => {
showSaveToast('设置已保存');
setTimeout(closePanel, 350);
});
overlay.addEventListener('click', (e) => {
if (e.target === overlay) closePanel();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlay.style.display === 'flex') closePanel();
});
refreshPanelHomepageSection();
updateDetailState();
return overlay;
}
function toggleConfigPanel() {
let overlay = document.getElementById('izModalOverlay');
if (!overlay) overlay = createConfigPanel();
if (overlay.style.display === 'flex') {
overlay.style.display = 'none';
return;
}
// 每次打开面板都把「总览」与「全部控件」校正为真实状态(统一入口见 syncPanelFromState)。
if (overlay.__iznGo) overlay.__iznGo('overview');
const syncAll = () => { if (overlay.__izSyncAll) overlay.__izSyncAll(); else if (overlay.__iznSync) overlay.__iznSync(); };
syncAll();
setTimeout(syncAll, 80);
refreshPanelHomepageSection();
overlay.classList.add('anim-in');
overlay.style.display = 'flex';
setTimeout(() => overlay.classList.remove('anim-in'), 400);
}
// 12. 主初始化
// 颜色标记:🟢 性能/结构优化区 · 🟡 兼容性相关区(改动需重点测试)· 🔴 hover 放大核心链路(避免直接重构)。
function mainInit() {
loadConfig();
loadState();
injectStyles();
// ★ 调试钩子:仅在 URL 带 ?hvdebug=1 时暴露,用于端到端验证(如防盗链抓取),不影响正常使用
try {
if (/[?&]hvdebug=1/.test(location.search)) {
window.__hvDebug = { gmFetchBlobUrl, upgradeImgUrl, buildHdCandidates, sizeHintOf, probeCandidates, pickBestCandidate, VERSION: SCRIPT_VERSION };
}
} catch (e) { }
// ★ UI 入口(dock 控制球/介绍面板/更新弹窗)只在顶层 frame 创建:
// iframe 里再浮一个控制球既无必要,还会与顶层 UI 重叠、拦截鼠标事件。
if (IS_TOP) {
createDockButton();
if (isEnabled) {
setTimeout(showIntroPanel, 180);
setTimeout(showUpdateNotice, 260);
}
}
// ★ frame 被卸载(SPA 路由/iframe 移除)时释放仲裁心跳,别让别的 frame 等 TTL
window.addEventListener('pagehide', () => arbiterStopRenew());
// ★ 全局 mousemove:坐标权威源(停稳裁决器/心跳/TIMER_FIRE 使用)。
// 直接写入不节流——handler 本身只有三次赋值,开销可忽略;
// 停稳裁决器通过 debounce 自身控制频率,无需在此节流。
document.addEventListener('mousemove', (e) => {
const x = e.clientX, y = e.clientY;
const wasResumeBlocked = resumeBlockedUntilMouseMove;
// ★ 关键修复:切回窗口时浏览器可能补发 mousemove,甚至坐标会发生微小变化。
// 在恢复保护期间,只接受真正带有物理指针位移的 mousemove。
// movementX/movementY 是浏览器提供的本次指针位移量;切回窗口产生的补发事件通常为 0。
const physicalMouseMove = Number(e.movementX || 0) !== 0 || Number(e.movementY || 0) !== 0;
const movedAfterResume = !wasResumeBlocked || physicalMouseMove;
// ★ 恢复保护期间,浏览器可能补发“假 mousemove”。
// 这类事件不仅不能解除保护,也不应污染 lastMouse 的权威坐标;
// 否则切回窗口时旧坐标可能被补发事件覆盖,后续停稳裁决/心跳会误判。
if (!wasResumeBlocked || physicalMouseMove) {
lastMouse.x = x;
lastMouse.y = y;
lastMouse.t = Date.now();
if (hoverWaitIndicator && hoverWaitIndicator.classList.contains('show')) {
positionHoverWaitIndicator(x, y + 22);
}
pointerInWindow = true;
browserWindowFocused = true;
}
// ★ 切换应用后的恢复保护:没有真实鼠标位移就一直保持阻塞。
// 防止 Alt+Tab / Finder / 文件管理器切回时自动补发事件再次触发放大。
if (movedAfterResume) {
resumeBlockedUntilMouseMove = false;
}
}, { passive: true });
window.addEventListener('resize', debounce(() => {
invalidateLightboxState();
if (isEnabled) zoomFSM.dispatch('DISMISS');
}, 250));
setupLightboxObserver();
if (isEnabled) initImages();
bilibiliVolumeModule.init(); setupGlobalHoverStream(); // ★ mouseover 流 + 停稳裁决器 双保险
videoPreviewModule.init(); // ★ 视频悬停预览:滚动/失焦/变形即关闭
setupHeartbeat(); // ★ 持续复核(PENDING/ACTIVE,带失败容忍,后台页暂停)
setupBgRuleProxy();
setupAutoBackgroundHover();
// ★ 规则包:先用本地缓存立刻生效,再按需后台更新(失败静默降级,不影响基本功能)
loadRulePackFromStorage();
if (config.rulePackAuto) setTimeout(function () { updateRulePack(false); }, 1500);
keymapModule.init(); // ★ 键位系统:Esc 关闭 / +/- 缩放 / 0 原始尺寸
startObserver();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mainInit);
} else {
mainInit();
}
})();