// ==UserScript== // @name 鼠标悬停图片自动放大预览 // @namespace http://tampermonkey.net/ // @version 3.3.3 // @description 一款好用的网页图片放大工具,鼠标悬停即可自动放大图片,支持自定义配置,适配所有网页~ // @author 益达哥哥 // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @run-at document-end // @license MIT // ==/UserScript== (function() { 'use strict'; // ================================ // 图片放大功能模块 // ================================ const imageZoomModule = (function() { const defaultConfig = { delay: 500, scale: 3, maxWidth: 1200, maxHeight: 980, minScale: 1.4, portraitRatio: 1.3, wrapperZIndex: 1, zoomZIndex: 9999, transition: 'all 0.3s ease', scrollSpeed: 50, smallImgThreshold: 280, smallImgWidth: 500, smallImgHeight: 430, avoidClickConflict: true }; const states = new WeakMap(); let isEnabled = true; let isMinimized = false; let originalPos = null; let config = { ...defaultConfig }; const currentDomain = getDomain(); let currentZoomContainer = null; let toggleButton = null; let excludedHomepages = []; const fullBtnWidth = 80; const fullBtnHeight = 32; const miniBtnDiameter = 32; const miniBtnRadius = miniBtnDiameter / 2; const exposeRatio = 0.15; // 【调试开关】改为 true 后,被跳过的图片会在控制台 (F12) 打印原因 const DEBUG_IMAGE_CHECK = false; function getDomain() { try { return new URL(window.location.href).hostname; } catch (e) { return window.location.hostname; } } function getCurrentHomepage() { const url = new URL(window.location.href); return `${url.protocol}//${url.hostname}/`; } function isHomepageExcluded() { const currentHomepage = getCurrentHomepage(); return excludedHomepages.includes(currentHomepage); } function loadExcludedHomepages() { const saved = GM_getValue(`image_zoom_excluded_homepages_${currentDomain}`, []); excludedHomepages = Array.isArray(saved) ? saved : []; } function saveExcludedHomepages() { GM_setValue(`image_zoom_excluded_homepages_${currentDomain}`, excludedHomepages); } function toggleCurrentHomepageExclusion() { const currentHomepage = getCurrentHomepage(); const index = excludedHomepages.indexOf(currentHomepage); if (index > -1) { excludedHomepages.splice(index, 1); showToast('已启用当前主页的图片放大功能'); } else { excludedHomepages.push(currentHomepage); showToast('已禁用当前主页的图片放大功能'); if (isHomepage() && isEnabled) { cleanup(); } } saveExcludedHomepages(); updateExclusionButtonState(); updateButtonState(); } function isHomepage() { const path = window.location.pathname; return path === '/' || path === '/index.html' || path === '/index.php' || path === ''; } function showToast(message) { 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, 0.8); color: white; padding: 12px 20px; border-radius: 6px; z-index: 100000; font-size: 14px; font-family: Arial, sans-serif; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; `; document.body.appendChild(toast); } toast.textContent = message; toast.style.opacity = '1'; setTimeout(() => { toast.style.opacity = '0'; }, 2000); } function debounce(func, wait) { let timeout; return function(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } function validateConfig(cfg) { const validated = { ...cfg }; validated.delay = Math.max(0, Math.min(2000, validated.delay)); validated.scale = Math.max(1, Math.min(10, validated.scale)); validated.maxWidth = Math.max(100, Math.min(5000, validated.maxWidth)); validated.maxHeight = Math.max(100, Math.min(5000, validated.maxHeight)); validated.minScale = Math.max(1, Math.min(5, validated.minScale)); validated.portraitRatio = Math.max(1, Math.min(5, validated.portraitRatio)); validated.scrollSpeed = Math.max(1, Math.min(100, validated.scrollSpeed)); validated.smallImgThreshold = Math.max(50, Math.min(1000, validated.smallImgThreshold)); validated.smallImgWidth = Math.max(100, Math.min(2000, validated.smallImgWidth)); validated.smallImgHeight = Math.max(100, Math.min(2000, validated.smallImgHeight)); return validated; } function loadConfig() { const savedConfig = GM_getValue(`image_zoom_config_${currentDomain}`); if (savedConfig) { const validatedConfig = validateConfig(savedConfig); config = { ...defaultConfig, ...validatedConfig }; } } function saveConfig() { GM_setValue(`image_zoom_config_${currentDomain}`, config); } function loadState() { const savedState = GM_getValue(`image_zoom_enabled_${currentDomain}`); isEnabled = savedState !== false; if (isHomepage() && isHomepageExcluded()) { isEnabled = false; } } function injectStyles() { const style = document.createElement('style'); style.textContent = ` .image-zoom-wrapper * { box-sizing: border-box; } .image-zoom-container img { object-fit: contain; } #image-zoom-toggle { all: initial; font-family: Arial, sans-serif; } .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); } function createToggleButton() { const button = document.createElement('div'); button.id = 'image-zoom-toggle'; button.className = 'zoom-btn'; button.style.cssText = ` position: fixed; bottom: 20px; right: 20px; width: ${fullBtnWidth}px; height: ${fullBtnHeight}px; border-radius: ${fullBtnHeight/2}px; background-color: #4CAF50; color: white; display: flex; align-items: center; justify-content: space-between; padding: 0 12px; cursor: pointer; z-index: 99999; box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: all 0.5s ease; font-weight: bold; font-size: 12px; opacity: 0.2; overflow: visible; user-select: none; `; const textSpan = document.createElement('span'); textSpan.className = 'btn-text'; textSpan.textContent = '图片放大:开'; textSpan.style.transition = 'opacity 0.3s ease'; const arrowSpan = document.createElement('span'); arrowSpan.className = 'btn-arrow'; arrowSpan.textContent = '▶'; arrowSpan.style.cssText = `margin-left: 5px; font-size: 14px; transition: transform 0.3s ease;`; button.appendChild(textSpan); button.appendChild(arrowSpan); button.title = '左键:切换功能 / 右键:打开设置 / 点击箭头:吸附边缘'; const savedPos = GM_getValue(`image_zoom_button_pos_${currentDomain}`); if (savedPos) { button.style.top = savedPos.top; button.style.left = savedPos.left; button.style.bottom = "auto"; button.style.right = "auto"; } button.addEventListener('mouseenter', () => button.style.opacity = '1'); button.addEventListener('mouseleave', () => button.style.opacity = isMinimized ? '0.5' : '0.2'); button.addEventListener('click', (e) => { if (e.target.classList.contains('btn-arrow') || e.target.parentNode.classList.contains('btn-arrow')) { e.stopPropagation(); if (isMinimized) restoreButton(); else minimizeButton(); return; } if (e.button !== 0) return; toggleEnabled(); }); button.addEventListener('contextmenu', (e) => { e.preventDefault(); toggleConfigPanel(); }); makeDraggable(button); document.body.appendChild(button); updateButtonState(); return button; } function minimizeButton() { if (isMinimized) return; originalPos = { top: toggleButton.style.top, left: toggleButton.style.left, bottom: toggleButton.style.bottom, right: toggleButton.style.right, width: toggleButton.style.width, height: toggleButton.style.height }; const rect = toggleButton.getBoundingClientRect(); const viewportWidth = window.innerWidth; const distances = { right: viewportWidth - rect.right, left: rect.left }; const nearestEdge = Object.entries(distances).sort((a, b) => a[1] - b[1])[0][0]; toggleButton.style.top = 'auto'; toggleButton.style.left = 'auto'; toggleButton.style.bottom = 'auto'; toggleButton.style.right = 'auto'; toggleButton.style.width = `${miniBtnDiameter}px`; toggleButton.style.height = `${miniBtnDiameter}px`; toggleButton.style.borderRadius = '50%'; toggleButton.querySelector('.btn-text').style.opacity = '0'; const arrowSpan = toggleButton.querySelector('.btn-arrow'); const exposeWidth = miniBtnDiameter * exposeRatio; arrowSpan.style.cssText = `margin: 0; font-size: 12px; position: absolute; top: 50%; transform: translateY(-50%);`; if (nearestEdge === 'left') { arrowSpan.textContent = '▶'; arrowSpan.style.left = `${miniBtnDiameter * (1 - exposeRatio) + exposeWidth * 0.5}px`; } else { arrowSpan.textContent = '◀'; arrowSpan.style.left = `${exposeWidth * 0.5}px`; } const centerY = rect.top + rect.height/2; const hiddenOffset = miniBtnDiameter * (1 - exposeRatio); if (nearestEdge === 'left') { toggleButton.style.left = `-${hiddenOffset}px`; toggleButton.style.top = `${centerY - miniBtnRadius}px`; } else { toggleButton.style.right = `-${hiddenOffset}px`; toggleButton.style.top = `${centerY - miniBtnRadius}px`; } toggleButton.style.opacity = '0.5'; isMinimized = true; } function restoreButton() { if (!isMinimized || !originalPos) return; toggleButton.style.width = originalPos.width; toggleButton.style.height = originalPos.height; toggleButton.style.borderRadius = `${fullBtnHeight/2}px`; toggleButton.querySelector('.btn-text').style.opacity = '1'; const arrowSpan = toggleButton.querySelector('.btn-arrow'); arrowSpan.style.cssText = `margin-left: 5px; font-size: 14px; transition: transform 0.3s ease; position: static; transform: none;`; const originalLeft = parseFloat(originalPos.left) || 0; const viewportHalf = window.innerWidth / 2; arrowSpan.textContent = originalLeft < viewportHalf ? '◀' : '▶'; toggleButton.style.top = originalPos.top; toggleButton.style.left = originalPos.left; toggleButton.style.bottom = originalPos.bottom; toggleButton.style.right = originalPos.right; toggleButton.style.opacity = '0.2'; isMinimized = false; } function toggleEnabled() { isEnabled = !isEnabled; GM_setValue(`image_zoom_enabled_${currentDomain}`, isEnabled); updateButtonState(); if (isEnabled) { initImages(); } else { cleanup(); } } function cleanup() { if (currentZoomContainer) { currentZoomContainer.remove(); currentZoomContainer = null; } document.querySelectorAll('.image-zoom-container').forEach(el => el.remove()); document.querySelectorAll('.image-zoom-wrapper').forEach(wrapper => { const parent = wrapper.parentNode; while (wrapper.firstChild) parent.insertBefore(wrapper.firstChild, wrapper); parent.removeChild(wrapper); }); document.querySelectorAll('img.image-zoom-processed').forEach(img => { const state = states.get(img); if (state) { if (state.timer) clearTimeout(state.timer); if (state.handlers) { img.removeEventListener('mouseenter', state.handlers.mouseenter); img.removeEventListener('mouseleave', state.handlers.mouseleave); img.removeEventListener('click', state.handlers.click, true); if (state.handlers.parent && state.handlers.parentMouseenter) { state.handlers.parent.removeEventListener('mouseenter', state.handlers.parentMouseenter); state.handlers.parent.removeEventListener('mouseleave', state.handlers.parentMouseleave); } } if (state.containers) { state.containers.forEach(ctn => { ctn.classList.remove('image-zoom-hover'); ctn.removeAttribute('data-zoom-proxy'); ctn.removeAttribute('data-stretch-fixed'); }); } } img.classList.remove('image-zoom-processed'); img.classList.remove('image-zoom-hover'); states.delete(img); }); } function updateButtonState() { if (!toggleButton) return; const textSpan = toggleButton.querySelector('.btn-text'); if (isHomepage() && isHomepageExcluded()) { textSpan.textContent = '主页已排除'; toggleButton.style.backgroundColor = '#ff9800'; } else { textSpan.textContent = isEnabled ? '图片放大:开' : '图片放大:关'; toggleButton.style.backgroundColor = isEnabled ? '#4CAF50' : '#f44336'; } } function makeDraggable(element) { let isDragging = false; element.onmousedown = (e) => { if (isMinimized) { restoreButton(); return; } if (e.button !== 0) return; e.preventDefault(); isDragging = true; document.addEventListener('mousemove', dragMove); document.addEventListener('mouseup', dragEnd); }; function dragMove(e) { if (!isDragging) return; e.preventDefault(); const buttonHalfWidth = element.offsetWidth / 2; const buttonHalfHeight = element.offsetHeight / 2; const left = e.clientX - buttonHalfWidth; const top = e.clientY - buttonHalfHeight; element.style.top = `${top}px`; element.style.left = `${left}px`; element.style.bottom = "auto"; element.style.right = "auto"; const arrowSpan = element.querySelector('.btn-arrow'); const viewportHalf = window.innerWidth / 2; arrowSpan.textContent = left < viewportHalf ? '◀' : '▶'; } function dragEnd() { if (!isDragging) return; isDragging = false; GM_setValue(`image_zoom_button_pos_${currentDomain}`, { top: element.style.top, left: element.style.left }); document.removeEventListener('mousemove', dragMove); document.removeEventListener('mouseup', dragEnd); } } function updateExclusionButtonState() { const exclusionBtn = document.getElementById('homepage-exclusion-btn'); const exclusionStatus = document.getElementById('exclusion-status'); if (exclusionBtn && exclusionStatus) { const isExcluded = isHomepageExcluded(); exclusionBtn.textContent = isExcluded ? '启用主页功能' : '禁用主页功能'; exclusionBtn.style.backgroundColor = isExcluded ? '#4CAF50' : '#ff9800'; exclusionStatus.textContent = isExcluded ? '当前主页已禁用图片放大' : '当前主页已启用图片放大'; exclusionStatus.style.color = isExcluded ? '#ff9800' : '#4CAF50'; } } function checkImageClickBehavior(img) { const commonSelectors = ['[onclick*="zoom"]', '[onclick*="lightbox"]', '[onclick*="gallery"]', '[onclick*="preview"]', '[data-action*="zoom"]', '[data-lightbox]', '[data-gallery]', '[data-fancybox]', '.zoomable', '.lightbox', '.gallery-item', '.fancybox', '.stretched-link']; for (const selector of commonSelectors) { if (img.matches(selector)) return true; } let parent = img.parentElement; while (parent && parent !== document.body) { const parentClass = parent.className || ''; const parentId = parent.id || ''; if (parentClass.includes('zoom') || parentClass.includes('lightbox') || parentClass.includes('gallery') || parentClass.includes('fancybox') || parentClass.includes('stretched-link') || parentId.includes('zoom') || parentId.includes('lightbox') || parentId.includes('gallery') || parentId.includes('fancybox')) { return true; } parent = parent.parentElement; } return false; } function isImageInLightboxMode(img) { const commonLightboxSelectors = ['.lightbox-open', '.fancybox-open', '.modal-open', '.zoom-overlay-open']; for (const selector of commonLightboxSelectors) { if (document.body.classList.contains(selector.replace('.', '')) || document.documentElement.classList.contains(selector.replace('.', ''))) { return true; } } return false; } // 增强的图片验证:兼容 Discuz 轮播图/选项卡/懒加载 function isValidImage(img) { if (!img || !img.parentNode) return false; if (img.tagName !== 'IMG') return false; // 用 getClientRects 替代 offsetParent,兼容 position:fixed 及特殊布局 if (img.getClientRects().length === 0) { if (DEBUG_IMAGE_CHECK) console.log('[图片放大] 跳过(不可见):', img.src); return false; } const style = window.getComputedStyle(img); if (style.display === 'none' || style.visibility === 'hidden' || parseFloat(style.opacity) === 0) { if (DEBUG_IMAGE_CHECK) console.log('[图片放大] 暂时跳过(隐藏/淡出中):', img.src); return false; } const src = img.src || img.currentSrc; if (!src || src.trim() === '' || src.startsWith('data:') || src.includes('placeholder')) { if (DEBUG_IMAGE_CHECK) console.log('[图片放大] 跳过(无效src):', src); return false; } if (style.backgroundImage && style.backgroundImage !== 'none') return false; // 未加载完的图片,等 load 后自动重新检测(懒加载兼容) if (!img.complete || img.naturalWidth === 0) { if (!img.dataset.zoomLoadBound) { img.dataset.zoomLoadBound = 'true'; img.addEventListener('load', () => processImage(img), { once: true }); } return false; } const rect = img.getBoundingClientRect(); if (rect.width < 10 || rect.height < 10) return false; return true; } function createConfigPanel() { const panel = document.createElement('div'); panel.id = 'image-zoom-config'; panel.style.cssText = `position: fixed; bottom: 70px; right: 20px; width: 320px; background: white; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.2); padding: 15px; z-index: 99998; display: none; font-family: Arial, sans-serif; max-height: 80vh; overflow-y: auto;`; const title = document.createElement('h3'); title.textContent = '图片放大设置'; title.style.cssText = 'margin: 0 0 15px; padding-bottom: 8px; border-bottom: 1px solid #eee;'; panel.appendChild(title); const exclusionSection = document.createElement('div'); exclusionSection.style.cssText = `margin: 15px 0; padding: 12px; background: #f8f9fa; border-radius: 6px; border-left: 4px solid #ff9800;`; const exclusionTitle = document.createElement('h4'); exclusionTitle.textContent = '如果此主页面图片不显示可以尝试禁用'; exclusionTitle.style.cssText = 'margin: 0 0 8px; color: #333;'; exclusionSection.appendChild(exclusionTitle); const exclusionDesc = document.createElement('p'); exclusionDesc.textContent = '在当前网站主页禁用图片放大功能,不影响其他的子页面'; exclusionDesc.style.cssText = 'margin: 0 0 10px; font-size: 12px; color: #666;'; exclusionSection.appendChild(exclusionDesc); const exclusionStatus = document.createElement('div'); exclusionStatus.id = 'exclusion-status'; exclusionStatus.style.cssText = 'margin: 8px 0; font-size: 13px; font-weight: bold;'; exclusionSection.appendChild(exclusionStatus); const exclusionBtn = document.createElement('button'); exclusionBtn.id = 'homepage-exclusion-btn'; exclusionBtn.style.cssText = `width: 100%; padding: 8px; background: #ff9800; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 13px;`; exclusionBtn.addEventListener('click', toggleCurrentHomepageExclusion); exclusionSection.appendChild(exclusionBtn); const exclusionListTitle = document.createElement('h5'); exclusionListTitle.textContent = '已排除的主页:'; exclusionListTitle.style.cssText = 'margin: 15px 0 8px; color: #333;'; exclusionSection.appendChild(exclusionListTitle); const exclusionList = document.createElement('div'); exclusionList.id = 'exclusion-list'; exclusionList.style.cssText = 'max-height: 120px; overflow-y: auto; font-size: 12px;'; exclusionSection.appendChild(exclusionList); panel.appendChild(exclusionSection); function updateExclusionList() { exclusionList.innerHTML = ''; if (excludedHomepages.length === 0) { const emptyMsg = document.createElement('div'); emptyMsg.textContent = '暂无排除的主页'; emptyMsg.style.cssText = 'color: #999; font-style: italic; padding: 5px;'; exclusionList.appendChild(emptyMsg); } else { excludedHomepages.forEach(homepage => { const item = document.createElement('div'); item.style.cssText = `display: flex; justify-content: space-between; align-items: center; padding: 4px 8px; margin: 2px 0; background: white; border-radius: 3px; border: 1px solid #eee;`; const urlSpan = document.createElement('span'); urlSpan.textContent = homepage; urlSpan.style.cssText = 'flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;'; const removeBtn = document.createElement('button'); removeBtn.textContent = '移除'; removeBtn.style.cssText = `background: #f44336; color: white; border: none; border-radius: 3px; padding: 2px 6px; font-size: 11px; cursor: pointer; margin-left: 8px;`; removeBtn.addEventListener('click', () => { const index = excludedHomepages.indexOf(homepage); if (index > -1) { excludedHomepages.splice(index, 1); saveExcludedHomepages(); updateExclusionList(); updateExclusionButtonState(); updateButtonState(); showToast('已移除主页排除'); } }); item.appendChild(urlSpan); item.appendChild(removeBtn); exclusionList.appendChild(item); }); } } const avoidConflictItem = document.createElement('div'); avoidConflictItem.style.marginBottom = '12px'; const avoidConflictLabel = document.createElement('label'); avoidConflictLabel.style.cssText = 'display: flex; align-items: center; margin-bottom: 5px; font-size: 14px; cursor: pointer;'; const avoidConflictCheckbox = document.createElement('input'); avoidConflictCheckbox.type = 'checkbox'; avoidConflictCheckbox.checked = config.avoidClickConflict; avoidConflictCheckbox.style.cssText = 'margin-right: 8px;'; avoidConflictCheckbox.addEventListener('change', () => { config.avoidClickConflict = avoidConflictCheckbox.checked; saveConfig(); if (isEnabled) { cleanup(); initImages(); } }); const avoidConflictText = document.createElement('span'); avoidConflictText.textContent = '避免与点击放大功能冲突'; avoidConflictLabel.appendChild(avoidConflictCheckbox); avoidConflictLabel.appendChild(avoidConflictText); avoidConflictItem.appendChild(avoidConflictLabel); const avoidConflictDesc = document.createElement('div'); avoidConflictDesc.textContent = '开启后会自动检测网站的点击放大功能,避免冲突'; avoidConflictDesc.style.cssText = 'font-size: 12px; color: #666; margin-top: 4px;'; avoidConflictItem.appendChild(avoidConflictDesc); panel.appendChild(avoidConflictItem); function addConfigInput(label, key, type = 'number', min, max, step) { const item = document.createElement('div'); item.style.marginBottom = '12px'; const itemLabel = document.createElement('label'); itemLabel.style.cssText = 'display: block; margin-bottom: 5px; font-size: 14px;'; itemLabel.textContent = label; item.appendChild(itemLabel); const input = document.createElement('input'); input.type = type; input.value = config[key]; input.min = min; input.max = max; input.step = step; input.style.cssText = 'width: 100%; padding: 6px; border: 1px solid #ddd; border-radius: 4px;'; input.addEventListener('input', () => { let value = type === 'number' ? parseFloat(input.value) : input.value; if (isNaN(value)) value = defaultConfig[key]; config[key] = value; saveConfig(); }); item.appendChild(input); panel.appendChild(item); } addConfigInput('悬停延迟(毫秒)', 'delay', 'number', 0, 2000, 100); addConfigInput('大图放大倍数', 'scale', 'number', 1, 5, 0.1); addConfigInput('大图最小倍数', 'minScale', 'number', 1, 3, 0.1); addConfigInput('大图最大宽度(像素)', 'maxWidth', 'number', 300, 3000, 100); addConfigInput('大图最大高度(像素)', 'maxHeight', 'number', 300, 3000, 100); addConfigInput('竖屏判定比例', 'portraitRatio', 'number', 1, 3, 0.1); addConfigInput('滚轮移动速度(像素)', 'scrollSpeed', 'number', 5, 50, 1); addConfigInput('小图判定阈值(像素)', 'smallImgThreshold', 'number', 100, 500, 10); addConfigInput('小图强制宽度(像素)', 'smallImgWidth', 'number', 300, 1000, 10); addConfigInput('小图强制高度(像素)', 'smallImgHeight', 'number', 300, 1000, 10); addConfigInput('包装层层级', 'wrapperZIndex', 'number', 0, 100, 1); const bilibiliVolumeSection = document.createElement('div'); bilibiliVolumeSection.style.cssText = `margin: 20px 0 15px 0; padding: 12px; background: #f0f8ff; border-radius: 6px; border-left: 4px solid #2196F3;`; const bilibiliTitle = document.createElement('h4'); bilibiliTitle.textContent = 'B站播放器辅助'; bilibiliTitle.style.cssText = 'margin: 0 0 8px; color: #333;'; bilibiliVolumeSection.appendChild(bilibiliTitle); const bilibiliDesc = document.createElement('p'); bilibiliDesc.textContent = 'B站全屏时滚轮调节音量(脚本接管);方向键只防页面穿透滚动,音量/快进调节由B站原生逻辑处理'; bilibiliDesc.style.cssText = 'margin: 0 0 10px; font-size: 12px; color: #666;'; bilibiliVolumeSection.appendChild(bilibiliDesc); const bilibiliLabel = document.createElement('label'); bilibiliLabel.style.cssText = 'display: flex; align-items: center; margin-bottom: 5px; font-size: 14px; cursor: pointer;'; const bilibiliCheckbox = document.createElement('input'); bilibiliCheckbox.type = 'checkbox'; bilibiliCheckbox.checked = bilibiliVolumeModule.isEnabled; bilibiliCheckbox.style.cssText = 'margin-right: 8px;'; bilibiliCheckbox.addEventListener('change', () => { bilibiliVolumeModule.setEnabled(bilibiliCheckbox.checked); }); const bilibiliText = document.createElement('span'); bilibiliText.textContent = '启用B站播放器辅助'; bilibiliLabel.appendChild(bilibiliCheckbox); bilibiliLabel.appendChild(bilibiliText); bilibiliVolumeSection.appendChild(bilibiliLabel); panel.appendChild(bilibiliVolumeSection); const resetBtn = document.createElement('button'); resetBtn.textContent = '恢复默认设置'; resetBtn.style.cssText = `width: 100%; padding: 8px; background: #f5f5f5; border: 1px solid #ddd; border-radius: 4px; cursor: pointer; margin-top: 10px;`; resetBtn.addEventListener('click', () => { if (confirm('确定要恢复默认设置吗?')) { config = { ...defaultConfig }; saveConfig(); const newPanel = createConfigPanel(); if (panel.parentNode) { panel.parentNode.replaceChild(newPanel, panel); } } }); panel.appendChild(resetBtn); document.body.appendChild(panel); updateExclusionList(); updateExclusionButtonState(); return panel; } function toggleConfigPanel() { const panel = document.getElementById('image-zoom-config'); if (panel) { panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; if (panel.style.display === 'block') { updateExclusionList(); updateExclusionButtonState(); } } } // 【改造】放大态滚轮处理,返回 true 表示事件已被放大功能接管 function onWheel(e) { if (currentZoomContainer && isEnabled) { e.preventDefault(); e.stopPropagation(); debounceMove(e); return true; } return false; } const debounceMove = debounce(function(e) { if (!currentZoomContainer || !isEnabled) return; const zoomedImg = currentZoomContainer.querySelector('img'); if (!zoomedImg) return; const moveDistance = e.deltaY > 0 ? -config.scrollSpeed : config.scrollSpeed; const currentMarginTop = parseFloat(zoomedImg.style.marginTop) || 0; zoomedImg.style.marginTop = (currentMarginTop + moveDistance) + 'px'; }, 16); function handleResize() { if (isMinimized) { minimizeButton(); } } function addKeyboardSupport() { document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { const panel = document.getElementById('image-zoom-config'); if (panel && panel.style.display !== 'none') { panel.style.display = 'none'; } } if (e.altKey && e.key === 'z') { e.preventDefault(); toggleEnabled(); } }); } function createZoomedImage(img, hasClickFunctionality = false) { if (!isEnabled) return null; try { const rect = img.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return null; const isSmallImg = rect.width < config.smallImgThreshold || rect.height < config.smallImgThreshold; const isPortrait = rect.height / rect.width > config.portraitRatio; let scale, zoomedImgStyle; if (isSmallImg) { scale = isPortrait ? config.smallImgHeight / rect.height : config.smallImgWidth / rect.width; zoomedImgStyle = `width: ${rect.width * scale}px; height: ${rect.height * scale}px; transform: scale(0.95); transition: ${config.transition}; box-shadow: 0 4px 20px rgba(0,0,0,0.2); margin-top: 0;`; } else { if (isPortrait) { const heightScale = config.maxHeight / rect.height; scale = Math.min(config.scale, heightScale, Math.max(config.minScale, 1.2)); } else { const widthScale = config.maxWidth / rect.width; const heightScale = config.maxHeight / rect.height; scale = Math.min(config.scale, widthScale, heightScale, Math.max(config.minScale, 1)); } const maxHeight = isPortrait ? config.maxHeight + 200 : config.maxHeight; zoomedImgStyle = `max-width: ${config.maxWidth}px; max-height: ${maxHeight}px; width: auto; height: auto; transform: scale(0.95); transition: ${config.transition}; box-shadow: 0 4px 20px rgba(0,0,0,0.2); margin-top: 0;`; } const zoomContainer = document.createElement('div'); zoomContainer.className = 'image-zoom-container'; const zoomZIndex = hasClickFunctionality ? config.zoomZIndex - 1 : config.zoomZIndex; zoomContainer.style.cssText = `position: fixed; z-index: ${zoomZIndex}; opacity: 0; transition: ${config.transition}; pointer-events: none; left: 0; top: 0; width: 100%; height: 100%; display: flex; justify-content: center; align-items: center; padding: 20px; box-sizing: border-box; background-color: rgba(0,0,0,0.1);`; const zoomedImg = document.createElement('img'); zoomedImg.src = img.src || img.currentSrc; zoomedImg.alt = img.alt; zoomedImg.style.cssText = zoomedImgStyle; zoomContainer.appendChild(zoomedImg); document.body.appendChild(zoomContainer); if (currentZoomContainer) currentZoomContainer.remove(); currentZoomContainer = zoomContainer; setTimeout(() => { zoomContainer.style.opacity = '1'; zoomedImg.style.transform = `scale(${scale})`; }, 10); return zoomContainer; } catch (error) { console.warn('创建放大图失败:', error); return null; } } function showZoom(img) { if (!isEnabled) return; if (config.avoidClickConflict && isImageInLightboxMode(img)) return; const state = states.get(img); if (!state || state.isZoomed) return; const zoomContainer = createZoomedImage(img, state.hasClickFunctionality); if (!zoomContainer) return; states.set(img, { ...state, isZoomed: true, zoomContainer }); } function hideZoom(img) { const state = states.get(img); if (!state || !state.isZoomed || !state.zoomContainer) return; const zoomedImg = state.zoomContainer.querySelector('img'); if (zoomedImg) zoomedImg.style.transform = 'scale(0.95)'; state.zoomContainer.style.opacity = '0'; setTimeout(() => { if (state.zoomContainer && state.zoomContainer.parentNode) { state.zoomContainer.parentNode.removeChild(state.zoomContainer); } if (currentZoomContainer === state.zoomContainer) currentZoomContainer = null; states.set(img, { ...state, isZoomed: false, zoomContainer: null, timer: null }); }, 300); } function handleStretchedLink(img) { const card = img.closest('.card'); if (!card) return; const link = card.querySelector('a.stretched-link'); if (!link || link.dataset.stretchFixed) return; link.dataset.stretchFixed = '1'; link.addEventListener('mouseenter', () => { if (img.classList.contains('image-zoom-processed')) { img.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); } }); link.addEventListener('mouseleave', () => { if (img.classList.contains('image-zoom-processed')) { img.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); } }); } function processImage(img) { if (!isEnabled) return; try { if (!img || !img.parentNode) return; if (!isValidImage(img)) return; let containers = []; let current = img.parentNode; while (current && current !== document.body) { containers.push(current); if (current.tagName === 'A' || current.classList.contains('card') || current.classList.contains('col') || current.classList.contains('card-img-container')) break; current = current.parentNode; } const container = containers.length > 0 ? containers[containers.length - 1] : img.parentNode; const hasClickFunctionality = config.avoidClickConflict ? (checkImageClickBehavior(img) || container.tagName === 'A' || container.classList.contains('stretched-link')) : false; if (img.classList.contains('image-zoom-processed')) return; img.classList.add('image-zoom-processed'); if (!hasClickFunctionality) { img.classList.add('image-zoom-hover'); if (img.parentNode && img.parentNode !== document.body) { img.parentNode.classList.add('image-zoom-hover'); } } handleStretchedLink(img); const directParent = img.parentNode; const handleParentMouseEnter = (e) => { const rect = img.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { img.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true, cancelable: true })); } }; const handleParentMouseLeave = (e) => { if (!directParent.contains(e.relatedTarget)) { img.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true, cancelable: true })); } }; if (directParent && directParent !== document.body) { if (!directParent.dataset.zoomProxy) { directParent.dataset.zoomProxy = "true"; directParent.addEventListener('mouseenter', handleParentMouseEnter); directParent.addEventListener('mouseleave', handleParentMouseLeave); } } let timer = null; let isZoomed = false; let zoomContainer = null; const handleMouseEnter = (e) => { if (!isEnabled || isZoomed) return; if (config.avoidClickConflict && isImageInLightboxMode(img)) return; if (timer) clearTimeout(timer); timer = setTimeout(() => { if (!isZoomed) { zoomContainer = createZoomedImage(img, hasClickFunctionality); if (zoomContainer) isZoomed = true; } }, config.delay); }; const handleMouseLeave = (e) => { if (timer) { clearTimeout(timer); timer = null; } if (isZoomed && zoomContainer) { const zoomedImg = zoomContainer.querySelector('img'); if (zoomedImg) zoomedImg.style.transform = 'scale(0.95)'; zoomContainer.style.opacity = '0'; setTimeout(() => { if (zoomContainer && zoomContainer.parentNode) { zoomContainer.parentNode.removeChild(zoomContainer); } if (currentZoomContainer === zoomContainer) currentZoomContainer = null; isZoomed = false; zoomContainer = null; }, 300); } }; const handleClick = (e) => { if (isZoomed && zoomContainer) hideZoom(img); }; img.addEventListener('mouseenter', handleMouseEnter); img.addEventListener('mouseleave', handleMouseLeave); img.addEventListener('click', handleClick, true); // 保存事件处理函数引用,便于清理 states.set(img, { isZoomed, timer, zoomContainer, hasClickFunctionality, containers: [directParent], handlers: { mouseenter: handleMouseEnter, mouseleave: handleMouseLeave, click: handleClick, parent: directParent, parentMouseenter: handleParentMouseEnter, parentMouseleave: handleParentMouseLeave } }); } catch (error) { console.warn('图片处理失败:', error); } } // 悬停自愈机制:第一次悬停未处理的图片时,重新检测并直接触发放大 function setupLazyHoverProcessor() { document.addEventListener('mouseover', debounce((e) => { if (!isEnabled || (isHomepage() && isHomepageExcluded())) return; let img = null; if (e.target.tagName === 'IMG') img = e.target; else if (e.target.closest) img = e.target.closest('img'); if (img && !img.classList.contains('image-zoom-processed')) { processImage(img); if (img.classList.contains('image-zoom-processed')) { img.dispatchEvent(new MouseEvent('mouseenter')); } } }, 80), true); } function setupLightboxObserver() { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && (mutation.target === document.body || mutation.target === document.documentElement)) { if (isImageInLightboxMode(null)) { document.querySelectorAll('.image-zoom-container').forEach(container => { container.style.opacity = '0'; setTimeout(() => { if (container.parentNode) container.parentNode.removeChild(container); }, 300); }); currentZoomContainer = null; document.querySelectorAll('img.image-zoom-processed').forEach(img => { const state = states.get(img); if (state) { states.set(img, { ...state, isZoomed: false, zoomContainer: null }); } }); } } }); }); observer.observe(document.body, { attributes: true, attributeFilter: ['class'] }); observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); } function initImages() { if (isHomepage() && isHomepageExcluded()) return; const imgs = Array.from(document.querySelectorAll('img:not(.image-zoom-processed)')); // 分批处理防卡顿,使用 requestIdleCallback const processBatch = (deadline) => { while (imgs.length > 0 && (deadline.timeRemaining ? deadline.timeRemaining() > 0 : true)) { const img = imgs.shift(); processImage(img); } if (imgs.length > 0) { if ('requestIdleCallback' in window) { requestIdleCallback(processBatch, { timeout: 1000 }); } else { setTimeout(() => processBatch({ timeRemaining: () => 5 }), 10); } } }; if ('requestIdleCallback' in window) { requestIdleCallback(processBatch, { timeout: 1000 }); } else { processBatch({ timeRemaining: () => 5 }); } } function startObserver() { let processingQueue = false; const observer = new MutationObserver(mutations => { if (!isEnabled || (isHomepage() && isHomepageExcluded())) return; if (processingQueue) return; processingQueue = true; // requestAnimationFrame 节流,合并多次 Mutation 回调 requestAnimationFrame(() => { const nodesToProcess = new Set(); mutations.forEach(mutation => { // 处理属性变化(懒加载图片 src 更新) if (mutation.type === 'attributes' && mutation.target.tagName === 'IMG' && !mutation.target.classList.contains('image-zoom-processed')) { nodesToProcess.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')) { nodesToProcess.add(node); } else if (node.tagName === 'A' && node.classList.contains('stretched-link')) { const img = node.closest('.card')?.querySelector('img.image-zoom-processed'); if (img) handleStretchedLink(img); } else if (node.tagName === 'A' && node.querySelector('img')) { const img = node.querySelector('img'); if (img && !img.classList.contains('image-zoom-processed')) nodesToProcess.add(img); } else if (node.querySelectorAll) { node.querySelectorAll('img:not(.image-zoom-processed)').forEach(img => nodesToProcess.add(img)); node.querySelectorAll('a.stretched-link').forEach(link => { const img = link.closest('.card')?.querySelector('img.image-zoom-processed'); if (img) handleStretchedLink(img); }); } }); } }); nodesToProcess.forEach(img => processImage(img)); processingQueue = false; }); }); // 监听 attributes 变化,适配懒加载 observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'data-src', 'srcset'] }); } function init() { loadConfig(); loadExcludedHomepages(); loadState(); injectStyles(); toggleButton = createToggleButton(); createConfigPanel(); addKeyboardSupport(); window.addEventListener('resize', debounce(handleResize, 250)); setupLightboxObserver(); if (isEnabled) initImages(); setupLazyHoverProcessor(); // 悬停自愈机制 startObserver(); // 注意:wheel 监听已移至 mainInit 的统一调度器 } return { init, cleanup, onWheel }; })(); // ================================ // B站播放器辅助模块(v3.3.1 修正版) // 滚轮调音量:脚本接管(B站原生滚轮在网页全屏下不可靠,已实测) // 方向键:守卫模式,交还 B站原生(已实测可用) // 音量提示:滚轮直接显示 + volumechange 监听兜底(覆盖拖音量条等场景) // ================================ const bilibiliVolumeModule = (function() { let isEnabled = GM_getValue('bilibili_volume_enabled', true); let toast = null; let currentFullscreenElement = null; function isInFullscreenMode() { const fullscreenElement = document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement; if (fullscreenElement) { currentFullscreenElement = fullscreenElement; return true; } if (document.body.classList.contains('player-mode-webfullscreen')) { currentFullscreenElement = document.body; return true; } const player = document.querySelector('.bpx-player-container'); if (player && player.classList.contains('state-fullscreen')) { currentFullscreenElement = player; return true; } currentFullscreenElement = null; return false; } function findVideoElement() { if (currentFullscreenElement) { const video = currentFullscreenElement.querySelector('video'); if (video) return video; } return document.querySelector('.bpx-player-container video, video'); } // 优先走 B站官方 player API(0~100),播放器自己的音量条状态会同步更新 function applyVolume(video, newVolume) { const clamped = Math.max(0, Math.min(1, newVolume)); try { const player = window.player; if (player && typeof player.setVolume === 'function') { player.setVolume(Math.round(clamped * 100)); if (clamped > 0) { if (typeof player.setMute === 'function') player.setMute(false); else video.muted = false; } return; } } catch (err) { /* API 不可用时降级 */ } video.volume = clamped; video.muted = false; } function getVolume(video) { try { const player = window.player; if (player && typeof player.getVolume === 'function') return player.getVolume() / 100; } catch (err) {} return video.volume; } function getVolumeIcon(volume) { if (volume === 0) { return ``; } return ``; } function showVolumeToast(volume) { if (!toast) { toast = document.createElement('div'); toast.id = 'bilibili-volume-toast'; toast.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(255, 255, 255, 0.9); color: #333; padding: 8px 16px; border-radius: 8px; z-index: 2147483647; font-size: 26px; font-weight: 300; font-family: 'Segoe UI', Arial, sans-serif; opacity: 0; transition: opacity 0.3s ease; pointer-events: none; box-shadow: 0 4px 20px rgba(0,0,0,0.2); border: 1px solid rgba(0,0,0,0.1); min-width: 90px; text-align: center; backdrop-filter: blur(10px); display: flex; align-items: center; justify-content: center; gap: 8px; `; } const parentElement = currentFullscreenElement || document.body; if (!toast.parentNode || toast.parentNode !== parentElement) { if (toast.parentNode) toast.parentNode.removeChild(toast); parentElement.appendChild(toast); } toast.innerHTML = getVolumeIcon(volume) + `${volume === 0 ? '静音' : Math.round(volume * 100) + '%'}`; toast.style.opacity = '1'; if (toast.timeoutId) clearTimeout(toast.timeoutId); toast.timeoutId = setTimeout(() => { if (toast) toast.style.opacity = '0'; }, 2000); } // 【滚轮】脚本接管:调音量 + 防穿透(B站原生滚轮调音量在网页全屏下不可靠) function onWheel(e) { if (!isEnabled || !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; } // 【方向键守卫】保留:只挡页面穿透滚动,调节交给 B站原生(已实测可用) function handleKeydown(e) { if (!isEnabled) return; if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.isContentEditable) return; if ((e.code === 'ArrowUp' || e.code === 'ArrowDown' || e.code === 'ArrowLeft' || e.code === 'ArrowRight') && isInFullscreenMode()) { e.preventDefault(); } } // 【音量提示】监听 volumechange(capture 阶段,不依赖冒泡) // 覆盖非脚本来源的音量变化(如拖动B站音量条),统一弹出提示 function handleVolumeChange(e) { if (!isEnabled) return; if (e.target.tagName !== 'VIDEO') return; if (!isInFullscreenMode()) return; const video = e.target; showVolumeToast(video.muted ? 0 : video.volume); } function init() { if (!window.location.hostname.includes('bilibili.com')) return; isInFullscreenMode(); window.addEventListener('keydown', handleKeydown); document.addEventListener('volumechange', handleVolumeChange, { capture: true }); } function setEnabled(enabled) { isEnabled = enabled; GM_setValue('bilibili_volume_enabled', enabled); if (enabled) { window.addEventListener('keydown', handleKeydown); document.addEventListener('volumechange', handleVolumeChange, { capture: true }); } else { window.removeEventListener('keydown', handleKeydown); document.removeEventListener('volumechange', handleVolumeChange, { capture: true }); } } return { init, setEnabled, onWheel, get isEnabled() { return isEnabled; } }; })(); // ================================ // 主初始化函数 // ================================ function mainInit() { imageZoomModule.init(); bilibiliVolumeModule.init(); // 【统一滚轮调度】全脚本唯一 wheel 监听器 // 优先级:图片放大态 > B站全屏滚轮音量 > 浏览器默认行为 document.addEventListener('wheel', (e) => { if (imageZoomModule.onWheel(e)) return; // 放大图滚动大图(stopPropagation) bilibiliVolumeModule.onWheel(e); // B站全屏:脚本接管调音量 // 都不接管时,不 preventDefault,页面正常滚动 }, { capture: true, passive: false }); } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mainInit); } else { mainInit(); } })();