// ==UserScript==
// @name Better web animation 网页动画改进
// @namespace http://tampermonkey.net/
// @version 4.4
// @description 为所有网页的新加载、变化、移动和消失的内容提供可配置的平滑显现和动画效果,包括图片和瞬间变化的元素。优化性能,避免与滚动检测等功能冲突。
// @match *://*/*
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @license CC BY-NC 4.0
// @downloadurl https://update.gf.qytechs.cn/scripts/515833/Better%20web%20animation%20%E7%BD%91%E9%A1%B5%E5%8A%A8%E7%94%BB%E6%94%B9%E8%BF%9B.user.js
// @updateurl https://update.gf.qytechs.cn/scripts/515833/Better%20web%20animation%20%E7%BD%91%E9%A1%B5%E5%8A%A8%E7%94%BB%E6%94%B9%E8%BF%9B.user.js
// ==/UserScript==
(function() {
'use strict';
// 多语言支持
const translations = {
en: {
settingsTitle: 'Animation Effect Settings',
fadeInDuration: 'Fade-in Duration (seconds):',
fadeOutDuration: 'Fade-out Duration (seconds):',
transitionDuration: 'Transition Duration (seconds):',
animationTypes: 'Animation Types:',
fade: 'Fade',
zoom: 'Zoom',
rotate: 'Rotate',
slide: 'Slide',
excludedTags: 'Excluded Tags (separated by commas):',
observeAttributes: 'Observe Attribute Changes',
observeCharacterData: 'Observe Text Changes',
detectFrequentChanges: 'Detect Frequently Changing Elements',
changeThreshold: 'Frequent Change Threshold (times):',
detectionDuration: 'Detection Duration (milliseconds):',
saveConfig: 'Save Settings',
cancelConfig: 'Cancel',
settings: 'Settings'
},
zh: {
settingsTitle: '动画效果设置',
fadeInDuration: '渐显持续时间(秒):',
fadeOutDuration: '渐隐持续时间(秒):',
transitionDuration: '属性过渡持续时间(秒):',
animationTypes: '动画类型:',
fade: '淡入/淡出(Fade)',
zoom: '缩放(Zoom)',
rotate: '旋转(Rotate)',
slide: '滑动(Slide)',
excludedTags: '排除的标签(用逗号分隔):',
observeAttributes: '观察属性变化',
observeCharacterData: '观察文本变化',
detectFrequentChanges: '检测频繁变化的元素',
changeThreshold: '频繁变化阈值(次):',
detectionDuration: '检测持续时间(毫秒):',
saveConfig: '保存设置',
cancelConfig: '取消',
settings: '设置'
}
};
const userLang = navigator.language.startsWith('zh') ? 'zh' : 'en';
const t = translations[userLang];
// 默认配置
const defaultConfig = {
fadeInDuration: 0.5, // 渐显持续时间(秒)
fadeOutDuration: 0.5, // 渐隐持续时间(秒)
transitionDuration: 0.5, // 属性过渡持续时间(秒)
animationTypes: ['fade'], // 动画类型:'fade', 'zoom', 'rotate', 'slide'
excludedTags: ['script'], // 排除的标签
observeAttributes: true, // 观察属性变化
observeCharacterData: true, // 观察文本变化
detectFrequentChanges: true, // 检测频繁变化
changeThreshold: 10, // 频繁变化阈值(次)
detectionDuration: 500, // 检测持续时间(毫秒)
};
// 加载用户配置
let userConfig = GM_getValue('userConfig', defaultConfig);
// 初始化频繁变化检测的记录
const changeRecords = new WeakMap();
// 添加菜单命令
GM_registerMenuCommand(t.settings, showConfigPanel);
// 添加全局样式
function addGlobalStyles() {
// 移除之前的样式
const existingStyle = document.getElementById('global-animation-styles');
if (existingStyle) existingStyle.remove();
// 动态生成动画样式
let animations = '';
// 渐显效果
if (userConfig.animationTypes.includes('fade')) {
animations += `
.fade-in-effect {
animation: fadeIn ${userConfig.fadeInDuration}s forwards;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: var(--original-opacity, 1); }
}
`;
}
// 缩放效果
if (userConfig.animationTypes.includes('zoom')) {
animations += `
.zoom-in-effect {
animation: zoomIn ${userConfig.fadeInDuration}s forwards;
}
@keyframes zoomIn {
from { transform: scale(0); }
to { transform: scale(1); }
}
`;
}
// 旋转效果
if (userConfig.animationTypes.includes('rotate')) {
animations += `
.rotate-in-effect {
animation: rotateIn ${userConfig.fadeInDuration}s forwards;
}
@keyframes rotateIn {
from { transform: rotate(-360deg); }
to { transform: rotate(0deg); }
}
`;
}
// 滑动效果
if (userConfig.animationTypes.includes('slide')) {
animations += `
.slide-in-effect {
animation: slideIn ${userConfig.fadeInDuration}s forwards;
}
@keyframes slideIn {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
`;
}
// 属性变化过渡效果
animations += `
.property-change-effect {
transition: all ${userConfig.transitionDuration}s ease-in-out;
}
`;
// 渐隐效果
animations += `
.fade-out-effect {
animation: fadeOut ${userConfig.fadeOutDuration}s forwards;
}
@keyframes fadeOut {
from { opacity: var(--original-opacity, 1); }
to { opacity: 0; }
}
`;
// 图片加载动画
animations += `
img.fade-in-effect {
animation: fadeIn ${userConfig.fadeInDuration}s forwards;
}
`;
// 添加样式到页面
const style = document.createElement('style');
style.id = 'global-animation-styles';
style.textContent = animations;
document.head.appendChild(style);
}
addGlobalStyles();
// 页面加载时,为整个页面应用平滑显现效果
function applyInitialFadeIn() {
document.body.style.opacity = '0';
document.body.style.transition = `opacity ${userConfig.fadeInDuration}s`;
window.addEventListener('load', () => {
document.body.style.opacity = '';
});
}
applyInitialFadeIn();
// 检查元素是否可见
function isElementVisible(element) {
return element.offsetWidth > 0 && element.offsetHeight > 0 && window.getComputedStyle(element).visibility !== 'hidden' && window.getComputedStyle(element).display !== 'none';
}
// 检查是否为要排除的 Bilibili 元素
let bilibiliExcludedElement = null;
function isBilibiliVideoPage() {
return window.location.href.startsWith('https://www.bilibili.com/video');
}
if (isBilibiliVideoPage()) {
const xpath = '//*[@id="bilibili-player"]/div/div/div[1]/div[1]/div[4]';
const result = document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
bilibiliExcludedElement = result.singleNodeValue;
}
// 应用进入动画效果
function applyEnterAnimations(element) {
// 检查是否在排除列表中
if (userConfig.excludedTags.includes(element.tagName.toLowerCase())) return;
// 检查元素是否可见
if (!isElementVisible(element)) return;
// 检查是否为要排除的 Bilibili 元素
if (element === bilibiliExcludedElement) return;
// 检查初始透明度
const computedStyle = window.getComputedStyle(element);
const initialOpacity = computedStyle.opacity;
// 保存原始透明度
element.style.setProperty('--original-opacity', initialOpacity);
// 清除之前的动画类
element.classList.remove('fade-in-effect', 'zoom-in-effect', 'rotate-in-effect', 'slide-in-effect');
// 添加动画类
if (userConfig.animationTypes.includes('fade')) {
element.classList.add('fade-in-effect');
}
if (userConfig.animationTypes.includes('zoom')) {
element.classList.add('zoom-in-effect');
}
if (userConfig.animationTypes.includes('rotate')) {
element.classList.add('rotate-in-effect');
}
if (userConfig.animationTypes.includes('slide')) {
element.classList.add('slide-in-effect');
}
// 监听动画结束,移除动画类,恢复元素状态
function handleAnimationEnd() {
element.classList.remove('fade-in-effect', 'zoom-in-effect', 'rotate-in-effect', 'slide-in-effect');
element.style.removeProperty('--original-opacity');
element.removeEventListener('animationend', handleAnimationEnd);
}
element.addEventListener('animationend', handleAnimationEnd);
}
// 应用属性变化过渡效果
function applyTransitionEffect(element) {
// 检查是否在排除列表中
if (userConfig.excludedTags.includes(element.tagName.toLowerCase())) return;
// 检查元素是否可见
if (!isElementVisible(element)) return;
// 检查是否为要排除的 Bilibili 元素
if (element === bilibiliExcludedElement) return;
if (!element.classList.contains('property-change-effect')) {
element.classList.add('property-change-effect');
// 监听过渡结束,移除过渡类,恢复元素状态
const removeTransitionClass = () => {
element.classList.remove('property-change-effect');
element.removeEventListener('transitionend', removeTransitionClass);
};
element.addEventListener('transitionend', removeTransitionClass);
}
}
// 应用离开动画效果
function applyExitAnimations(element) {
// 检查是否在排除列表中
if (userConfig.excludedTags.includes(element.tagName.toLowerCase())) return;
// 检查元素是否可见
if (!isElementVisible(element)) return;
// 检查是否为要排除的 Bilibili 元素
if (element === bilibiliExcludedElement) return;
// 如果元素已经有离开动画,直接返回
if (element.classList.contains('fade-out-effect')) return;
// 获取元素的原始透明度
const computedStyle = window.getComputedStyle(element);
const initialOpacity = computedStyle.opacity;
element.style.setProperty('--original-opacity', initialOpacity);
// 添加渐隐类
element.classList.add('fade-out-effect');
// 在动画结束后,从DOM中移除元素
function handleAnimationEnd() {
element.removeEventListener('animationend', handleAnimationEnd);
if (element.parentNode) {
element.parentNode.removeChild(element);
}
}
element.addEventListener('animationend', handleAnimationEnd);
}
// 检测频繁变化的元素
function isFrequentlyChanging(element) {
if (!userConfig.detectFrequentChanges) return false;
let record = changeRecords.get(element);
const now = Date.now();
if (!record) {
record = { count: 1, startTime: now };
changeRecords.set(element, record);
return false;
} else {
record.count++;
if (now - record.startTime < userConfig.detectionDuration) {
if (record.count >= userConfig.changeThreshold) {
return true;
} else {
return false;
}
} else {
// 重置记录
record.count = 1;
record.startTime = now;
return false;
}
}
}
// 使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver(mutations => {
// 使用 requestAnimationFrame 优化回调
requestAnimationFrame(() => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
// 在节点被添加时应用进入动画
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (!isFrequentlyChanging(node)) {
applyEnterAnimations(node);
}
}
});
// 在节点被移除前应用离开动画
mutation.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
if (!isFrequentlyChanging(node)) {
applyExitAnimations(node);
}
}
});
} else if ((mutation.type === 'attributes' && userConfig.observeAttributes) ||
(mutation.type === 'characterData' && userConfig.observeCharacterData)) {
const target = mutation.target;
if (target.nodeType === Node.ELEMENT_NODE) {
if (!isFrequentlyChanging(target)) {
applyTransitionEffect(target);
}
}
}
});
});
});
// 开始观察
function startObserving() {
observer.observe(document.body, {
childList: true,
attributes: userConfig.observeAttributes,
characterData: userConfig.observeCharacterData,
subtree: true,
attributeFilter: ['src', 'style', 'class'], // 观察属性变化,尤其是图片的'src'变化
});
}
startObserving();
// 对现有的图片元素应用动画
function applyAnimationsToExistingImages() {
document.querySelectorAll('img').forEach(img => {
if (!img.complete) {
img.addEventListener('load', () => {
applyEnterAnimations(img);
});
} else {
applyEnterAnimations(img);
}
});
}
applyAnimationsToExistingImages();
// 配置面板
function showConfigPanel() {
// 检查是否已存在配置面板
if (document.getElementById('animation-config-panel')) return;
// 创建配置面板的HTML结构
const panel = document.createElement('div');
panel.id = 'animation-config-panel';
panel.style.position = 'fixed';
panel.style.top = '50%';
panel.style.left = '50%';
panel.style.transform = 'translate(-50%, -50%)';
panel.style.backgroundColor = '#fff';
panel.style.border = '1px solid #ccc';
panel.style.padding = '20px';
panel.style.zIndex = '9999';
panel.style.maxWidth = '400px';
panel.style.boxShadow = '0 0 10px rgba(0,0,0,0.5)';
panel.style.overflowY = 'auto';
panel.style.maxHeight = '80%';
panel.innerHTML = `
<h2>${t.settingsTitle}</h2>
<label>
${t.fadeInDuration}
<input type="number" id="fadeInDuration" value="${userConfig.fadeInDuration}" step="0.1" min="0">
</label>
<br>
<label>
${t.fadeOutDuration}
<input type="number" id="fadeOutDuration" value="${userConfig.fadeOutDuration}" step="0.1" min="0">
</label>
<br>
<label>
${t.transitionDuration}
<input type="number" id="transitionDuration" value="${userConfig.transitionDuration}" step="0.1" min="0">
</label>
<br>
<label>
${t.animationTypes}
<br>
<input type="checkbox" id="animationFade" ${userConfig.animationTypes.includes('fade') ? 'checked' : ''}> ${t.fade}
<br>
<input type="checkbox" id="animationZoom" ${userConfig.animationTypes.includes('zoom') ? 'checked' : ''}> ${t.zoom}
<br>
<input type="checkbox" id="animationRotate" ${userConfig.animationTypes.includes('rotate') ? 'checked' : ''}> ${t.rotate}
<br>
<input type="checkbox" id="animationSlide" ${userConfig.animationTypes.includes('slide') ? 'checked' : ''}> ${t.slide}
</label>
<br>
<label>
${t.excludedTags}
<input type="text" id="excludedTags" value="${userConfig.excludedTags.join(',')}">
</label>
<br>
<label>
<input type="checkbox" id="observeAttributes" ${userConfig.observeAttributes ? 'checked' : ''}> ${t.observeAttributes}
</label>
<br>
<label>
<input type="checkbox" id="observeCharacterData" ${userConfig.observeCharacterData ? 'checked' : ''}> ${t.observeCharacterData}
</label>
<br>
<label>
<input type="checkbox" id="detectFrequentChanges" ${userConfig.detectFrequentChanges ? 'checked' : ''}> ${t.detectFrequentChanges}
</label>
<br>
<label>
${t.changeThreshold}
<input type="number" id="changeThreshold" value="${userConfig.changeThreshold}" min="1">
</label>
<br>
<label>
${t.detectionDuration}
<input type="number" id="detectionDuration" value="${userConfig.detectionDuration}" min="100">
</label>
<br><br>
<button id="saveConfig">${t.saveConfig}</button>
<button id="cancelConfig">${t.cancelConfig}</button>
`;
document.body.appendChild(panel);
// 添加事件监听
document.getElementById('saveConfig').addEventListener('click', () => {
// 保存配置
userConfig.fadeInDuration = parseFloat(document.getElementById('fadeInDuration').value) || defaultConfig.fadeInDuration;
userConfig.fadeOutDuration = parseFloat(document.getElementById('fadeOutDuration').value) || defaultConfig.fadeOutDuration;
userConfig.transitionDuration = parseFloat(document.getElementById('transitionDuration').value) || defaultConfig.transitionDuration;
const animationTypes = [];
if (document.getElementById('animationFade').checked) animationTypes.push('fade');
if (document.getElementById('animationZoom').checked) animationTypes.push('zoom');
if (document.getElementById('animationRotate').checked) animationTypes.push('rotate');
if (document.getElementById('animationSlide').checked) animationTypes.push('slide');
userConfig.animationTypes = animationTypes.length > 0 ? animationTypes : defaultConfig.animationTypes;
const excludedTags = document.getElementById('excludedTags').value.split(',').map(tag => tag.trim().toLowerCase()).filter(tag => tag);
userConfig.excludedTags = excludedTags.length > 0 ? excludedTags : defaultConfig.excludedTags;
userConfig.observeAttributes = document.getElementById('observeAttributes').checked;
userConfig.observeCharacterData = document.getElementById('observeCharacterData').checked;
userConfig.detectFrequentChanges = document.getElementById('detectFrequentChanges').checked;
userConfig.changeThreshold = parseInt(document.getElementById('changeThreshold').value) || defaultConfig.changeThreshold;
userConfig.detectionDuration = parseInt(document.getElementById('detectionDuration').value) || defaultConfig.detectionDuration;
// 保存到本地存储
GM_setValue('userConfig', userConfig);
// 更新样式和观察器
addGlobalStyles();
observer.disconnect();
startObserving();
// 对现有的图片重新应用动画
applyAnimationsToExistingImages();
// 移除配置面板
panel.remove();
});
document.getElementById('cancelConfig').addEventListener('click', () => {
// 移除配置面板
panel.remove();
});
}
})();