// ==UserScript==
// @name Websites Base64 Helper
// @icon https://raw.githubusercontent.com/XavierBar/Discourse-Base64-Helper/refs/heads/main/discourse.svg
// @namespace http://tampermonkey.net/
// @version 1.4.41
// @description Base64编解码工具 for all websites
// @author Xavier
// @match *://*/*
// @grant GM_notification
// @grant GM_setClipboard
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_addValueChangeListener
// @run-at document-idle
// @noframes true
// ==/UserScript==
(function () {
('use strict');
// 常量定义
const Z_INDEX = 2147483647;
const STORAGE_KEYS = {
BUTTON_POSITION: 'btnPosition',
SHOW_NOTIFICATION: 'showNotification',
HIDE_BUTTON: 'hideButton',
AUTO_DECODE: 'autoDecode',
};
// 存储管理器
const storageManager = {
get: (key, defaultValue) => {
try {
// 优先从 GM 存储获取
const value = GM_getValue(`base64helper_${key}`);
if (value !== undefined) {
return value;
}
// 尝试从 localStorage 迁移数据(兼容旧版本)
const localValue = localStorage.getItem(`base64helper_${key}`);
if (localValue !== null) {
const parsedValue = JSON.parse(localValue);
// 迁移数据到 GM 存储
GM_setValue(`base64helper_${key}`, parsedValue);
// 清理 localStorage 中的旧数据
localStorage.removeItem(`base64helper_${key}`);
return parsedValue;
}
return defaultValue;
} catch (e) {
console.error('Error getting value from storage:', e);
return defaultValue;
}
},
set: (key, value) => {
try {
// 存储到 GM 存储
GM_setValue(`base64helper_${key}`, value);
return true;
} catch (e) {
console.error('Error setting value to storage:', e);
return false;
}
},
// 添加删除方法
remove: (key) => {
try {
GM_deleteValue(`base64helper_${key}`);
return true;
} catch (e) {
console.error('Error removing value from storage:', e);
return false;
}
},
// 添加监听方法
addChangeListener: (key, callback) => {
return GM_addValueChangeListener(`base64helper_${key}`,
(_, oldValue, newValue, remote) => {
callback(newValue, oldValue, remote);
}
);
},
// 移除监听方法
removeChangeListener: (listenerId) => {
if (listenerId) {
GM_removeValueChangeListener(listenerId);
}
}
};
const BASE64_REGEX = /([A-Za-z0-9+/]+={0,2})(?!\w)/g;
// 样式常量
const STYLES = {
GLOBAL: `
/* 基础内容样式 */
.decoded-text {
cursor: pointer;
transition: all 0.2s;
padding: 1px 3px;
border-radius: 3px;
background-color: #fff3cd !important;
color: #664d03 !important;
}
.decoded-text:hover {
background-color: #ffe69c !important;
}
/* 通知动画 */
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
/* 暗色模式全局样式 */
@media (prefers-color-scheme: dark) {
.decoded-text {
background-color: #332100 !important;
color: #ffd54f !important;
}
.decoded-text:hover {
background-color: #664d03 !important;
}
}
`,
NOTIFICATION: `
@keyframes slideUpOut {
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateY(-30px) scale(0.95);
opacity: 0;
}
}
.base64-notifications-container {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: ${Z_INDEX};
display: flex;
flex-direction: column;
gap: 0;
pointer-events: none;
align-items: center;
width: fit-content;
}
.base64-notification {
transform-origin: top center;
white-space: nowrap;
padding: 12px 24px;
border-radius: 8px;
margin-bottom: 10px;
animation: slideIn 0.3s ease forwards;
font-family: system-ui, -apple-system, sans-serif;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
text-align: center;
line-height: 1.5;
background: rgba(255, 255, 255, 0.95);
color: #2d3748;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
opacity: 1;
transform: translateY(0);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform, opacity;
position: relative;
height: auto;
max-height: 100px;
}
.base64-notification.fade-out {
animation: slideUpOut 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
margin-bottom: 0 !important;
max-height: 0 !important;
padding-top: 0 !important;
padding-bottom: 0 !important;
border-width: 0 !important;
}
.base64-notification[data-type="success"] {
background: rgba(72, 187, 120, 0.95) !important;
color: #f7fafc !important;
}
.base64-notification[data-type="error"] {
background: rgba(245, 101, 101, 0.95) !important;
color: #f8fafc !important;
}
.base64-notification[data-type="info"] {
background: rgba(66, 153, 225, 0.95) !important;
color: #f7fafc !important;
}
@media (prefers-color-scheme: dark) {
.base64-notification {
background: rgba(26, 32, 44, 0.95) !important;
color: #e2e8f0 !important;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.05);
}
.base64-notification[data-type="success"] {
background: rgba(22, 101, 52, 0.95) !important;
}
.base64-notification[data-type="error"] {
background: rgba(155, 28, 28, 0.95) !important;
}
.base64-notification[data-type="info"] {
background: rgba(29, 78, 216, 0.95) !important;
}
}
`,
SHADOW_DOM: `
:host {
all: initial !important;
position: fixed !important;
z-index: ${Z_INDEX} !important;
pointer-events: none !important;
}
.base64-helper {
position: fixed;
z-index: ${Z_INDEX} !important;
transform: translateZ(100px);
cursor: drag;
font-family: system-ui, -apple-system, sans-serif;
opacity: 0.5;
transition: opacity 0.3s ease, transform 0.2s;
pointer-events: auto !important;
will-change: transform;
}
.base64-helper.dragging {
cursor: grabbing;
}
.base64-helper:hover {
opacity: 1 !important;
}
.main-btn {
background: #ffffff;
color: #000000 !important;
padding: 8px 16px;
border-radius: 6px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
font-weight: 500;
user-select: none;
transition: all 0.2s;
font-size: 14px;
cursor: drag;
border: none !important;
}
.main-btn.dragging {
cursor: grabbing;
}
.menu {
position: absolute;
background: #ffffff;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: none;
min-width: auto !important;
width: max-content !important;
overflow: hidden;
}
/* 菜单弹出方向 */
.menu.popup-top {
bottom: calc(100% + 5px);
}
.menu.popup-bottom {
top: calc(100% + 5px);
}
/* 新增: 左对齐样式 */
.menu.align-left {
left: 0;
}
.menu.align-left .menu-item {
text-align: left;
}
/* 新增: 右对齐样式 */
.menu.align-right {
right: 0;
}
.menu.align-right .menu-item {
text-align: right;
}
.menu-item {
padding: 8px 12px !important;
color: #333 !important;
transition: all 0.2s;
font-size: 13px;
cursor: pointer;
position: relative;
border-radius: 0 !important;
isolation: isolate;
white-space: nowrap !important;
// 新增以下样式防止文本被选中
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.menu-item:hover::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: currentColor;
opacity: 0.1;
z-index: -1;
}
@media (prefers-color-scheme: dark) {
.main-btn {
background: #2d2d2d;
color: #fff !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.menu {
background: #1a1a1a;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
}
.menu-item {
color: #e0e0e0 !important;
}
.menu-item:hover::before {
opacity: 0.08;
}
}
`,
};
// 样式初始化
const initStyles = () => {
GM_addStyle(STYLES.GLOBAL + STYLES.NOTIFICATION);
};
// 全局变量存储所有菜单命令ID
let menuIds = {
decode: null,
encode: null,
reset: null,
notification: null,
hideButton: null,
autoDecode: null
};
// 更新菜单命令
const updateMenuCommands = () => {
// 取消注册(不可用)所有菜单命令
Object.values(menuIds).forEach(id => {
if (id !== null) {
try {
GM_unregisterMenuCommand(id);
} catch (e) {
console.error('Failed to unregister menu command:', e);
}
}
});
// 重置菜单ID对象
menuIds = {
decode: null,
encode: null,
reset: null,
notification: null,
hideButton: null,
autoDecode: null
};
// 检查当前状态,决定解析菜单文本
const hasDecodedContent = document.querySelectorAll('.decoded-text').length > 0;
const decodeMenuText = hasDecodedContent ? '恢复本页 Base64' : '解析本页 Base64';
// 注册(不可用)解析菜单命令 - 放在第一位
try {
menuIds.decode = GM_registerMenuCommand(decodeMenuText, () => {
if (window.__base64HelperInstance) {
window.__base64HelperInstance.handleDecode();
}
});
console.log('Registered decode menu command with ID:', menuIds.decode);
} catch (e) {
console.error('Failed to register decode menu command:', e);
}
// 文本转 Base64
try {
menuIds.encode = GM_registerMenuCommand('文本转 Base64', () => {
if (window.__base64HelperInstance) window.__base64HelperInstance.handleEncode();
});
console.log('Registered encode menu command with ID:', menuIds.encode);
} catch (e) {
console.error('Failed to register encode menu command:', e);
}
// 重置按钮位置
try {
menuIds.reset = GM_registerMenuCommand('重置按钮位置', () => {
if (window.__base64HelperInstance) {
// 使用 storageManager 存储按钮位置
storageManager.set(STORAGE_KEYS.BUTTON_POSITION, {
x: window.innerWidth - 120,
y: window.innerHeight - 80,
});
window.__base64HelperInstance.initPosition();
window.__base64HelperInstance.showNotification('按钮位置已重置', 'success');
}
});
console.log('Registered reset menu command with ID:', menuIds.reset);
} catch (e) {
console.error('Failed to register reset menu command:', e);
}
// 显示解析通知开关
const showNotificationEnabled = storageManager.get(STORAGE_KEYS.SHOW_NOTIFICATION, true);
try {
menuIds.notification = GM_registerMenuCommand(`${showNotificationEnabled ? '✅' : '❌'} 显示通知`, () => {
const newValue = !showNotificationEnabled;
storageManager.set(STORAGE_KEYS.SHOW_NOTIFICATION, newValue);
// 使用通知提示用户设置已更改
if (window.__base64HelperInstance) {
window.__base64HelperInstance.showNotification(
`显示通知已${newValue ? '开启' : '关闭'}`,
'success'
);
}
// 更新菜单文本
setTimeout(updateMenuCommands, 100);
});
console.log('Registered notification menu command with ID:', menuIds.notification);
} catch (e) {
console.error('Failed to register notification menu command:', e);
}
// 隐藏按钮开关
const hideButtonEnabled = storageManager.get(STORAGE_KEYS.HIDE_BUTTON, false);
try {
menuIds.hideButton = GM_registerMenuCommand(`${hideButtonEnabled ? '✅' : '❌'} 隐藏按钮`, () => {
const newValue = !hideButtonEnabled;
storageManager.set(STORAGE_KEYS.HIDE_BUTTON, newValue);
// 使用通知提示用户设置已更改
if (window.__base64HelperInstance) {
window.__base64HelperInstance.showNotification(
`按钮已${newValue ? '隐藏' : '显示'}`,
'success'
);
}
// 更新菜单文本
setTimeout(updateMenuCommands, 100);
});
console.log('Registered hideButton menu command with ID:', menuIds.hideButton);
} catch (e) {
console.error('Failed to register hideButton menu command:', e);
}
// 自动解码开关
const autoDecodeEnabled = storageManager.get(STORAGE_KEYS.AUTO_DECODE, false);
try {
menuIds.autoDecode = GM_registerMenuCommand(`${autoDecodeEnabled ? '✅' : '❌'} 自动解码`, () => {
const newValue = !autoDecodeEnabled;
storageManager.set(STORAGE_KEYS.AUTO_DECODE, newValue);
// 如果启用自动解码但按钮未隐藏,提示用户
if (newValue && !storageManager.get(STORAGE_KEYS.HIDE_BUTTON, false)) {
if (confirm('建议同时隐藏按钮以获得更好的体验。\n\n是否同时隐藏按钮?')) {
storageManager.set(STORAGE_KEYS.HIDE_BUTTON, true);
}
}
// 使用通知提示用户设置已更改
if (window.__base64HelperInstance) {
window.__base64HelperInstance.showNotification(
`自动解码已${newValue ? '开启' : '关闭'}`,
'success'
);
}
// 更新菜单文本
setTimeout(updateMenuCommands, 100);
});
console.log('Registered autoDecode menu command with ID:', menuIds.autoDecode);
} catch (e) {
console.error('Failed to register autoDecode menu command:', e);
}
};
// 菜单命令注册(不可用)
const registerMenuCommands = () => {
// 注册(不可用)所有菜单命令
updateMenuCommands();
};
class Base64Helper {
/**
* Base64 Helper 类的构造函数
* @description 初始化所有必要的状态和UI组件,仅在主窗口中创建实例
* @throws {Error} 当在非主窗口中实例化时抛出错误
*/
constructor() {
// 确保只在主文档中创建实例
if (window.top !== window.self) {
throw new Error(
'Base64Helper can only be instantiated in the main window'
);
}
// 初始化配置
this.config = {
showNotification: storageManager.get(STORAGE_KEYS.SHOW_NOTIFICATION, true),
hideButton: storageManager.get(STORAGE_KEYS.HIDE_BUTTON, false),
autoDecode: storageManager.get(STORAGE_KEYS.AUTO_DECODE, false)
};
this.originalContents = new Map();
this.isDragging = false;
this.hasMoved = false;
this.startX = 0;
this.startY = 0;
this.initialX = 0;
this.initialY = 0;
this.startTime = 0;
this.menuVisible = false;
this.resizeTimer = null;
this.notifications = [];
this.notificationContainer = null;
this.notificationEventListeners = [];
this.eventListeners = [];
// 添加缓存对象
this.base64Cache = new Map();
this.MAX_CACHE_SIZE = 1000; // 最大缓存条目数
this.MAX_TEXT_LENGTH = 10000; // 最大文本长度限制
// 初始化配置监听器
this.configListeners = {
showNotification: null,
hideButton: null,
autoDecode: null,
buttonPosition: null
};
// 添加配置监听
this.setupConfigListeners();
// 初始化UI
this.initUI();
this.initEventListeners();
this.addRouteListeners();
// 如果启用了自动解码,则自动解析页面
if (this.config.autoDecode) {
// 使用延时确保页面已完全加载
setTimeout(() => this.handleDecode(), 1000);
}
}
/**
* 设置配置监听器
* @description 为各个配置项添加监听器,实现配置变更的实时响应
*/
setupConfigListeners() {
// 清理现有监听器
Object.values(this.configListeners).forEach(listenerId => {
if (listenerId) {
storageManager.removeChangeListener(listenerId);
}
});
// 监听显示通知设置变更
this.configListeners.showNotification = storageManager.addChangeListener(
STORAGE_KEYS.SHOW_NOTIFICATION,
(newValue) => {
console.log('显示通知设置已更改:', newValue);
this.config.showNotification = newValue;
}
);
// 监听隐藏按钮设置变更
this.configListeners.hideButton = storageManager.addChangeListener(
STORAGE_KEYS.HIDE_BUTTON,
(newValue) => {
console.log('隐藏按钮设置已更改:', newValue);
this.config.hideButton = newValue;
// 实时更新UI显示状态
const ui = this.shadowRoot?.querySelector('.base64-helper');
if (ui) {
ui.style.display = newValue ? 'none' : 'block';
}
}
);
// 监听自动解码设置变更
this.configListeners.autoDecode = storageManager.addChangeListener(
STORAGE_KEYS.AUTO_DECODE,
(newValue) => {
console.log('自动解码设置已更改:', newValue);
this.config.autoDecode = newValue;
// 如果启用了自动解码,立即解析页面
if (newValue) {
setTimeout(() => this.handleDecode(), 100);
}
}
);
// 监听按钮位置变更
this.configListeners.buttonPosition = storageManager.addChangeListener(
STORAGE_KEYS.BUTTON_POSITION,
(newValue) => {
console.log('按钮位置已更改:', newValue);
// 更新按钮位置
this.initPosition();
}
);
}
// 添加正则常量
static URL_PATTERNS = {
URL: /^(?:(?:https?|ftp):\/\/)?(?:(?:[\w-]+\.)+[a-z]{2,}|localhost)(?::\d+)?(?:\/[^\s]*)?$/i,
EMAIL: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
DOMAIN_PATTERNS: {
POPULAR_SITES:
/(?:google|youtube|facebook|twitter|instagram|linkedin|github|gitlab|bitbucket|stackoverflow|reddit|discord|twitch|tiktok|snapchat|pinterest|netflix|amazon|microsoft|apple|adobe)/i,
VIDEO_SITES:
/(?:bilibili|youku|iqiyi|douyin|kuaishou|nicovideo|vimeo|dailymotion)/i,
CN_SITES:
/(?:baidu|weibo|zhihu|taobao|tmall|jd|qq|163|sina|sohu|csdn|aliyun|tencent)/i,
TLD: /\.(?:com|net|org|edu|gov|mil|biz|info|io|cn|me|tv|cc|uk|jp|ru|eu|au|de|fr)(?:\/|\?|#|$)/i,
},
};
// UI 初始化
initUI() {
if (
window.top !== window.self ||
document.getElementById('base64-helper-root')
) {
return;
}
this.container = document.createElement('div');
this.container.id = 'base64-helper-root';
document.body.append(this.container);
this.shadowRoot = this.container.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(this.createShadowStyles());
// 创建 UI 容器
const uiContainer = document.createElement('div');
uiContainer.className = 'base64-helper';
uiContainer.style.cursor = 'grab';
// 创建按钮和菜单
this.mainBtn = this.createButton('Base64', 'main-btn');
this.menu = this.createMenu();
this.decodeBtn = this.menu.querySelector('[data-mode="decode"]');
this.encodeBtn = this.menu.querySelector('.menu-item:not([data-mode])');
// 添加到 UI 容器
uiContainer.append(this.mainBtn, this.menu);
this.shadowRoot.appendChild(uiContainer);
// 初始化位置
this.initPosition();
// 如果配置为隐藏按钮,则设置为不可见
if (this.config.hideButton) {
uiContainer.style.display = 'none';
}
}
createShadowStyles() {
const style = document.createElement('style');
style.textContent = STYLES.SHADOW_DOM;
return style;
}
// 不再需要 createMainUI 方法,因为我们直接在 initUI 中创建 UI
createButton(text, className) {
const btn = document.createElement('button');
btn.className = className;
btn.textContent = text;
return btn;
}
createMenu() {
const menu = document.createElement('div');
menu.className = 'menu';
this.decodeBtn = this.createMenuItem('解析本页 Base64', 'decode');
this.encodeBtn = this.createMenuItem('文本转 Base64');
menu.append(this.decodeBtn, this.encodeBtn);
return menu;
}
createMenuItem(text, mode) {
const item = document.createElement('div');
item.className = 'menu-item';
item.textContent = text;
if (mode) item.dataset.mode = mode;
return item;
}
// 位置管理
initPosition() {
const pos = this.positionManager.get() || {
x: window.innerWidth - 120,
y: window.innerHeight - 80,
};
const ui = this.shadowRoot.querySelector('.base64-helper');
ui.style.left = `${pos.x}px`;
ui.style.top = `${pos.y}px`;
// 新增: 初始化时更新菜单对齐
this.updateMenuAlignment();
}
updateMenuAlignment() {
const ui = this.shadowRoot.querySelector('.base64-helper');
const menu = this.menu;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
const uiRect = ui.getBoundingClientRect();
const centerX = uiRect.left + uiRect.width / 2;
const centerY = uiRect.top + uiRect.height / 2;
// 判断按钮是在页面左半边还是右半边
if (centerX < windowWidth / 2) {
// 左对齐
menu.classList.remove('align-right');
menu.classList.add('align-left');
} else {
// 右对齐
menu.classList.remove('align-left');
menu.classList.add('align-right');
}
// 判断按钮是在页面上半部分还是下半部分
if (centerY < windowHeight / 2) {
// 在页面上方,菜单向下弹出
menu.classList.remove('popup-top');
menu.classList.add('popup-bottom');
} else {
// 在页面下方,菜单向上弹出
menu.classList.remove('popup-bottom');
menu.classList.add('popup-top');
}
}
get positionManager() {
return {
get: () => {
// 使用 storageManager 获取按钮位置
const saved = storageManager.get(STORAGE_KEYS.BUTTON_POSITION, null);
if (!saved) return null;
const ui = this.shadowRoot.querySelector('.base64-helper');
const maxX = window.innerWidth - ui.offsetWidth - 20;
const maxY = window.innerHeight - ui.offsetHeight - 20;
return {
x: Math.min(Math.max(saved.x, 20), maxX),
y: Math.min(Math.max(saved.y, 20), maxY),
};
},
set: (x, y) => {
const ui = this.shadowRoot.querySelector('.base64-helper');
const pos = {
x: Math.max(
20,
Math.min(x, window.innerWidth - ui.offsetWidth - 20)
),
y: Math.max(
20,
Math.min(y, window.innerHeight - ui.offsetHeight - 20)
),
};
// 使用 storageManager 存储按钮位置
storageManager.set(STORAGE_KEYS.BUTTON_POSITION, pos);
return pos;
},
};
}
// 初始化事件监听器
initEventListeners() {
this.addUnifiedEventListeners();
this.addGlobalClickListeners();
// 核心编解码事件监听
const commonListeners = [
{
element: this.decodeBtn,
events: [
{
name: 'click',
handler: (e) => {
e.preventDefault();
e.stopPropagation();
this.handleDecode();
},
},
],
},
{
element: this.encodeBtn,
events: [
{
name: 'click',
handler: (e) => {
e.preventDefault();
e.stopPropagation();
this.handleEncode();
},
},
],
},
];
commonListeners.forEach(({ element, events }) => {
events.forEach(({ name, handler }) => {
element.addEventListener(name, handler, { passive: false });
this.eventListeners.push({ element, event: name, handler });
});
});
}
addUnifiedEventListeners() {
const ui = this.shadowRoot.querySelector('.base64-helper');
const btn = this.mainBtn;
// 统一的开始事件处理
const startHandler = (e) => {
e.preventDefault();
e.stopPropagation();
const point = e.touches ? e.touches[0] : e;
this.isDragging = true;
this.hasMoved = false;
this.startX = point.clientX;
this.startY = point.clientY;
const rect = ui.getBoundingClientRect();
this.initialX = rect.left;
this.initialY = rect.top;
this.startTime = Date.now();
ui.style.transition = 'none';
ui.classList.add('dragging');
btn.style.cursor = 'grabbing';
};
// 统一的移动事件处理
const moveHandler = (e) => {
if (!this.isDragging) return;
e.preventDefault();
e.stopPropagation();
const point = e.touches ? e.touches[0] : e;
const moveX = Math.abs(point.clientX - this.startX);
const moveY = Math.abs(point.clientY - this.startY);
if (moveX > 5 || moveY > 5) {
this.hasMoved = true;
const dx = point.clientX - this.startX;
const dy = point.clientY - this.startY;
const newX = Math.min(
Math.max(20, this.initialX + dx),
window.innerWidth - ui.offsetWidth - 20
);
const newY = Math.min(
Math.max(20, this.initialY + dy),
window.innerHeight - ui.offsetHeight - 20
);
ui.style.left = `${newX}px`;
ui.style.top = `${newY}px`;
}
};
// 统一的结束事件处理
const endHandler = (e) => {
if (!this.isDragging) return;
e.preventDefault();
e.stopPropagation();
this.isDragging = false;
ui.classList.remove('dragging');
btn.style.cursor = 'grab';
ui.style.transition = 'opacity 0.3s ease';
const duration = Date.now() - this.startTime;
if (duration < 200 && !this.hasMoved) {
this.toggleMenu(e);
} else if (this.hasMoved) {
const rect = ui.getBoundingClientRect();
const pos = this.positionManager.set(rect.left, rect.top);
ui.style.left = `${pos.x}px`;
ui.style.top = `${pos.y}px`;
// 新增: 拖动结束后更新菜单对齐
this.updateMenuAlignment();
}
};
// 统一收集所有事件监听器
const listeners = [
{
element: ui,
event: 'touchstart',
handler: startHandler,
options: { passive: false },
},
{
element: ui,
event: 'touchmove',
handler: moveHandler,
options: { passive: false },
},
{
element: ui,
event: 'touchend',
handler: endHandler,
options: { passive: false },
},
{ element: ui, event: 'mousedown', handler: startHandler },
{ element: document, event: 'mousemove', handler: moveHandler },
{ element: document, event: 'mouseup', handler: endHandler },
{
element: this.menu,
event: 'touchstart',
handler: (e) => e.stopPropagation(),
options: { passive: false },
},
{
element: this.menu,
event: 'mousedown',
handler: (e) => e.stopPropagation(),
},
{
element: window,
event: 'resize',
handler: () => this.handleResize(),
},
];
// 注册(不可用)事件并保存引用
listeners.forEach(({ element, event, handler, options }) => {
element.addEventListener(event, handler, options);
this.eventListeners.push({ element, event, handler, options });
});
}
toggleMenu(e) {
e?.preventDefault();
e?.stopPropagation();
// 如果正在拖动或已移动,不处理菜单切换
if (this.isDragging || this.hasMoved) return;
this.menuVisible = !this.menuVisible;
if (this.menuVisible) {
// 在显示菜单前更新位置
this.updateMenuAlignment();
}
this.menu.style.display = this.menuVisible ? 'block' : 'none';
// 重置状态
this.hasMoved = false;
}
addGlobalClickListeners() {
const handleOutsideClick = (e) => {
const ui = this.shadowRoot.querySelector('.base64-helper');
const path = e.composedPath();
if (!path.includes(ui) && this.menuVisible) {
this.menuVisible = false;
this.menu.style.display = 'none';
}
};
// 将全局点击事件添加到 eventListeners 数组
const globalListeners = [
{
element: document,
event: 'click',
handler: handleOutsideClick,
options: true,
},
{
element: document,
event: 'touchstart',
handler: handleOutsideClick,
options: { passive: false },
},
];
globalListeners.forEach(({ element, event, handler, options }) => {
element.addEventListener(event, handler, options);
this.eventListeners.push({ element, event, handler, options });
});
}
// 路由监听
addRouteListeners() {
this.handleRouteChange = () => {
clearTimeout(this.routeTimer);
this.routeTimer = setTimeout(() => this.resetState(), 100);
};
// 添加路由相关事件到 eventListeners 数组
const routeListeners = [
{ element: window, event: 'popstate', handler: this.handleRouteChange },
{
element: window,
event: 'hashchange',
handler: this.handleRouteChange,
},
{
element: window,
event: 'DOMContentLoaded',
handler: this.handleRouteChange,
},
];
routeListeners.forEach(({ element, event, handler }) => {
element.addEventListener(event, handler);
this.eventListeners.push({ element, event, handler });
});
// 修改 history 方法
this.originalPushState = history.pushState;
this.originalReplaceState = history.replaceState;
history.pushState = (...args) => {
this.originalPushState.apply(history, args);
this.handleRouteChange();
};
history.replaceState = (...args) => {
this.originalReplaceState.apply(history, args);
this.handleRouteChange();
};
}
/**
* 处理页面中的Base64解码操作
* @description 根据当前模式执行解码或恢复操作
* 如果当前模式是restore则恢复原始内容,否则查找并解码页面中的Base64内容
* @fires showNotification 显示操作结果通知
*/
handleDecode() {
// 存储当前模式的变量
let currentMode = 'decode';
// 如果按钮存在,使用按钮的模式
if (this.decodeBtn && this.decodeBtn.dataset.mode === 'restore') {
currentMode = 'restore';
}
// 如果是恢复模式
if (currentMode === 'restore') {
this.restoreContent();
return;
}
try {
// 隐藏菜单
if (this.menu && this.menu.style.display !== 'none') {
this.menu.style.display = 'none';
this.menuVisible = false;
}
// 使用 setTimeout 延迟执行以避免界面冻结
setTimeout(() => {
try {
const { nodesToReplace, validDecodedCount } = this.processTextNodes();
if (validDecodedCount === 0) {
this.showNotification('本页未发现有效 Base64 内容', 'info');
this.menuVisible = false;
this.menu.style.display = 'none';
return;
}
// 分批处理节点替换,避免大量 DOM 操作导致界面冻结
const BATCH_SIZE = 50; // 每批处理的节点数
const processNodesBatch = (startIndex) => {
const endIndex = Math.min(startIndex + BATCH_SIZE, nodesToReplace.length);
const batch = nodesToReplace.slice(startIndex, endIndex);
this.replaceNodes(batch);
if (endIndex < nodesToReplace.length) {
// 还有更多节点需要处理,安排下一批
setTimeout(() => processNodesBatch(endIndex), 0);
} else {
// 所有节点处理完成,添加点击监听器
this.addClickListenersToDecodedText();
this.decodeBtn.textContent = '恢复本页 Base64';
this.decodeBtn.dataset.mode = 'restore';
this.showNotification(
`解析完成,共找到 ${validDecodedCount} 个 Base64 内容`,
'success'
);
// 操作完成后更新菜单命令
setTimeout(updateMenuCommands, 100);
}
};
// 开始分批处理
processNodesBatch(0);
} catch (innerError) {
console.error('Base64 decode processing error:', innerError);
this.showNotification(`解析失败: ${innerError.message}`, 'error');
this.menuVisible = false;
this.menu.style.display = 'none';
}
}, 50); // 给浏览器一点时间渲染通知
} catch (e) {
console.error('Base64 decode error:', e);
this.showNotification(`解析失败: ${e.message}`, 'error');
this.menuVisible = false;
this.menu.style.display = 'none';
}
}
/**
* 处理文本节点中的Base64内容
* @description 遍历文档中的文本节点,查找并处理其中的Base64内容
* 注意: 此方法包含性能优化措施,如超时检测和节点过滤
* @returns {Object} 处理结果
* @property {Array} nodesToReplace - 需要替换的节点数组
* @property {number} validDecodedCount - 有效的Base64解码数量
*/
processTextNodes() {
const startTime = Date.now();
const TIMEOUT = 5000;
const excludeTags = new Set([
'script',
'style',
'noscript',
'iframe',
'img',
'input',
'textarea',
'svg',
'canvas',
'template',
'pre',
'code',
'button',
'meta',
'link',
'head',
'title',
'select',
'form',
'object',
'embed',
'video',
'audio',
'source',
'track',
'map',
'area',
'math',
'figure',
'picture',
'portal',
'slot',
'data',
'a',
'base', // 包含href属性的base标签
'param', // object的参数
'applet', // 旧版Java小程序
'frame', // 框架
'frameset', // 框架集
'marquee', // 滚动文本
'time', // 时间标签
'wbr', // 可能的换行符
'bdo', // 文字方向
'dialog', // 对话框
'details', // 详情
'summary', // 摘要
'menu', // 菜单
'menuitem', // 菜单项
'[hidden]', // 隐藏元素
'[aria-hidden="true"]', // 可访问性隐藏
'.base64', // 自定义class
'.encoded', // 自定义class
]);
const excludeAttrs = new Set([
'src',
'data-src',
'href',
'data-url',
'content',
'background',
'poster',
'data-image',
'srcset',
'data-background', // 背景图片
'data-thumbnail', // 缩略图
'data-original', // 原始图片
'data-lazy', // 懒加载
'data-defer', // 延迟加载
'data-fallback', // 后备图片
'data-preview', // 预览图
'data-avatar', // 头像
'data-icon', // 图标
'data-base64', // 显式标记的base64
'style', // 内联样式可能包含base64
'integrity', // SRI完整性校验
'crossorigin', // 跨域属性
'rel', // 关系属性
'alt', // 替代文本
'title', // 标题属性
]);
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
{
acceptNode: (node) => {
const isExcludedTag = (parent) => {
const tagName = parent.tagName?.toLowerCase();
return excludeTags.has(tagName);
};
const isHiddenElement = (parent) => {
if (!(parent instanceof HTMLElement)) return false;
const style = window.getComputedStyle(parent);
return (
style.display === 'none' ||
style.visibility === 'hidden' ||
style.opacity === '0' ||
style.clipPath === 'inset(100%)' ||
(style.height === '0px' && style.overflow === 'hidden')
);
};
const isOutOfViewport = (parent) => {
if (!(parent instanceof HTMLElement)) return false;
const rect = parent.getBoundingClientRect();
return rect.width === 0 || rect.height === 0;
};
const hasBase64Attributes = (parent) => {
if (!parent.hasAttributes()) return false;
for (const attr of parent.attributes) {
if (excludeAttrs.has(attr.name)) {
const value = attr.value.toLowerCase();
if (
value.includes('base64') ||
value.match(/^[a-z0-9+/=]+$/i)
) {
return true;
}
}
}
return false;
};
let parent = node.parentNode;
while (parent && parent !== document.body) {
if (
isExcludedTag(parent) ||
isHiddenElement(parent) ||
isOutOfViewport(parent) ||
hasBase64Attributes(parent)
) {
return NodeFilter.FILTER_REJECT;
}
parent = parent.parentNode;
}
const text = node.textContent?.trim();
if (!text) {
return NodeFilter.FILTER_SKIP;
}
return /[A-Za-z0-9+/]+/.exec(text)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_SKIP;
},
},
false
);
let nodesToReplace = [];
let processedMatches = new Set();
let validDecodedCount = 0;
while (walker.nextNode()) {
if (Date.now() - startTime > TIMEOUT) {
console.warn('Base64 processing timeout');
break;
}
const node = walker.currentNode;
const { modified, newHtml, count } = this.processMatches(
node.nodeValue,
processedMatches
);
if (modified) {
nodesToReplace.push({ node, newHtml });
validDecodedCount += count;
}
}
return { nodesToReplace, validDecodedCount };
}
/**
* 处理文本中的Base64匹配项
* @description 查找并处理文本中的Base64编码内容
* @param {string} text - 要处理的文本内容
* @param {Set} processedMatches - 已处理过的匹配项集合
* @returns {Object} 处理结果
* @property {boolean} modified - 文本是否被修改
* @property {string} newHtml - 处理后的HTML内容
* @property {number} count - 处理的Base64数量
*/
processMatches(text, processedMatches) {
const matches = Array.from(text.matchAll(BASE64_REGEX));
if (!matches.length) return { modified: false, newHtml: text, count: 0 };
let modified = false;
let newHtml = text;
let count = 0;
for (const match of matches.reverse()) {
const original = match[0];
// 使用 validateBase64 进行验证
if (!this.validateBase64(original)) {
console.log('Skipped: invalid Base64 string');
continue;
}
try {
const decoded = this.decodeBase64(original);
console.log('Decoded:', decoded);
if (!decoded) {
console.log('Skipped: decode failed');
continue;
}
// 将原始Base64和位置信息添加到已处理集合中,防止重复处理
const matchKey = `${original}-${match.index}`;
processedMatches.add(matchKey);
// 构建新的HTML内容:
// 1. 保留匹配位置之前的内容
const beforeMatch = newHtml.substring(0, match.index);
// 2. 插入解码后的内容,包装在span标签中
const decodedSpan = `<span class="decoded-text"
title="点击复制"
data-original="${original}">${decoded}</span>`;
// 3. 保留匹配位置之后的内容
const afterMatch = newHtml.substring(match.index + original.length);
// 组合新的HTML
newHtml = beforeMatch + decodedSpan + afterMatch;
// 标记内容已被修改
modified = true;
// 增加成功解码计数
count++;
// 记录日志
console.log('成功解码: 发现有意义的文本或中文字符');
} catch (e) {
console.error('Error processing:', e);
continue;
}
}
return { modified, newHtml, count };
}
/**
* 判断文本是否有意义
* @description 通过一系列规则判断解码后的文本是否具有实际意义
* @param {string} text - 要验证的文本
* @returns {boolean} 如果文本有意义返回true,否则返回false
*/
isMeaningfulText(text) {
// 1. 基本字符检查
if (!text || typeof text !== 'string') return false;
// 2. 长度检查
if (text.length < 2 || text.length > 10000) return false;
// 3. 文本质量检查
const stats = {
printable: 0, // 可打印字符
control: 0, // 控制字符
chinese: 0, // 中文字符
letters: 0, // 英文字母
numbers: 0, // 数字
punctuation: 0, // 标点符号
spaces: 0, // 空格
other: 0, // 其他字符
};
// 统计字符分布
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
const code = text.charCodeAt(i);
if (/[\u4E00-\u9FFF]/.test(char)) {
stats.chinese++;
stats.printable++;
} else if (/[a-zA-Z]/.test(char)) {
stats.letters++;
stats.printable++;
} else if (/[0-9]/.test(char)) {
stats.numbers++;
stats.printable++;
} else if (/[\s]/.test(char)) {
stats.spaces++;
stats.printable++;
} else if (/[!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~]/.test(char)) {
stats.punctuation++;
stats.printable++;
} else if (code < 32 || code === 127) {
stats.control++;
} else {
stats.other++;
}
}
// 4. 质量评估规则
const totalChars = text.length;
const printableRatio = stats.printable / totalChars;
const controlRatio = stats.control / totalChars;
const meaningfulRatio =
(stats.chinese + stats.letters + stats.numbers) / totalChars;
// 判断条件:
// 1. 可打印字符比例必须大于90%
// 2. 控制字符比例必须小于5%
// 3. 有意义字符(中文、英文、数字)比例必须大于30%
// 4. 空格比例不能过高(小于50%)
// 5. 其他字符比例必须很低(小于10%)
return (
printableRatio > 0.9 &&
controlRatio < 0.05 &&
meaningfulRatio > 0.3 &&
stats.spaces / totalChars < 0.5 &&
stats.other / totalChars < 0.1
);
}
/**
* 替换页面中的节点
* @description 使用新的HTML内容替换原有节点
* @param {Array} nodesToReplace - 需要替换的节点数组
* @param {Node} nodesToReplace[].node - 原始节点
* @param {string} nodesToReplace[].newHtml - 新的HTML内容
*/
replaceNodes(nodesToReplace) {
nodesToReplace.forEach(({ node, newHtml }) => {
const span = document.createElement('span');
span.innerHTML = newHtml;
node.parentNode.replaceChild(span, node);
});
}
/**
* 为解码后的文本添加点击复制功能
* @description 为所有解码后的文本元素添加点击事件监听器
* @fires copyToClipboard 点击时触发复制操作
* @fires showNotification 显示复制结果通知
*/
addClickListenersToDecodedText() {
document.querySelectorAll('.decoded-text').forEach((el) => {
el.addEventListener('click', async (e) => {
const success = await this.copyToClipboard(e.target.textContent);
this.showNotification(
success ? '已复制文本内容' : '复制失败,请手动复制',
success ? 'success' : 'error'
);
e.stopPropagation();
});
});
}
/**
* 处理文本编码为Base64
* @description 提示用户输入文本并转换为Base64格式
* @async
* @fires showNotification 显示编码结果通知
* @fires copyToClipboard 复制编码结果到剪贴板
*/
async handleEncode() {
// 隐藏菜单
if (this.menu && this.menu.style.display !== 'none') {
this.menu.style.display = 'none';
this.menuVisible = false;
}
const text = prompt('请输入要编码的文本:');
if (text === null) return; // 用户点击取消
// 添加空输入检查
if (!text.trim()) {
this.showNotification('请输入有效的文本内容', 'error');
return;
}
try {
// 处理输入文本:去除首尾空格和多余的换行符
const processedText = text.trim().replace(/[\r\n]+/g, '\n');
const encoded = this.encodeBase64(processedText);
const success = await this.copyToClipboard(encoded);
this.showNotification(
success
? 'Base64 已复制'
: '编码成功但复制失败,请手动复制:' + encoded,
success ? 'success' : 'info'
);
} catch (e) {
this.showNotification('编码失败: ' + e.message, 'error');
}
}
/**
* 验证Base64字符串
* @description 检查字符串是否为有效的Base64格式
* @param {string} str - 要验证的字符串
* @returns {boolean} 如果是有效的Base64返回true,否则返回false
* @example
* validateBase64('SGVsbG8gV29ybGQ=') // returns true
* validateBase64('Invalid-Base64') // returns false
*/
validateBase64(str) {
if (!str) return false;
// 使用缓存避免重复验证
if (this.base64Cache.has(str)) {
return this.base64Cache.get(str);
}
// 检查缓存大小并在必要时清理
if (this.base64Cache.size >= this.MAX_CACHE_SIZE) {
// 删除最早添加的缓存项
const oldestKey = this.base64Cache.keys().next().value;
this.base64Cache.delete(oldestKey);
}
// 1. 基本格式检查
// - 长度必须是4的倍数
// - 只允许包含合法的Base64字符
// - =号只能出现在末尾,且最多2个
if (
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(
str
)
) {
this.base64Cache.set(str, false);
return false;
}
// 2. 长度检查
// 过滤掉太短的字符串(至少8个字符)和过长的字符串(最多10000个字符)
if (str.length < 8 || str.length > 10000) {
this.base64Cache.set(str, false);
return false;
}
// 3. 特征检查
// 过滤掉可能是图片、视频等二进制数据的Base64
if (/^(?:data:|iVBOR|R0lGO|\/9j\/4|PD94bW|JVBER)/.test(str)) {
this.base64Cache.set(str, false);
return false;
}
// 添加到 validateBase64 方法中
const commonPatterns = {
// 常见的二进制数据头部特征
binaryHeaders:
/^(?:data:|iVBOR|R0lGO|\/9j\/4|PD94bW|JVBER|UEsDB|H4sIA|77u\/|0M8R4)/,
// 常见的文件类型标识
fileSignatures: /^(?:UEs|PK|%PDF|GIF8|RIFF|OggS|ID3|ÿØÿ|8BPS)/,
// 常见的编码标识
encodingMarkers:
/^(?:utf-8|utf-16|base64|quoted-printable|7bit|8bit|binary)/i,
// 可疑的URL模式
urlPatterns: /^(?:https?:|ftp:|data:|blob:|file:|ws:|wss:)/i,
// 常见的压缩文件头部
compressedHeaders: /^(?:eJw|H4s|Qk1Q|UEsD|N3q8|KLUv)/,
};
// 在验证时使用这些模式
if (
commonPatterns.binaryHeaders.test(str) ||
commonPatterns.fileSignatures.test(str) ||
commonPatterns.encodingMarkers.test(str) ||
commonPatterns.urlPatterns.test(str) ||
commonPatterns.compressedHeaders.test(str)
) {
this.base64Cache.set(str, false);
return false;
}
try {
const decoded = this.decodeBase64(str);
if (!decoded) {
this.base64Cache.set(str, false);
return false;
}
// 4. 解码后的文本验证
// 检查解码后的文本是否有意义
if (!this.isMeaningfulText(decoded)) {
this.base64Cache.set(str, false);
return false;
}
this.base64Cache.set(str, true);
return true;
} catch (e) {
console.error('Base64 validation error:', e);
this.base64Cache.set(str, false);
return false;
}
}
/**
* Base64解码
* @description 将Base64字符串解码为普通文本
* @param {string} str - 要解码的Base64字符串
* @returns {string|null} 解码后的文本,解码失败时返回null
* @example
* decodeBase64('SGVsbG8gV29ybGQ=') // returns 'Hello World'
*/
decodeBase64(str) {
try {
// 优化解码过程
const binaryStr = atob(str);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
} catch (e) {
console.error('Base64 decode error:', e);
return null;
}
}
/**
* Base64编码
* @description 将普通文本编码为Base64格式
* @param {string} str - 要编码的文本
* @returns {string|null} Base64编码后的字符串,编码失败时返回null
* @example
* encodeBase64('Hello World') // returns 'SGVsbG8gV29ybGQ='
*/
encodeBase64(str) {
try {
// 优化编码过程
const bytes = new TextEncoder().encode(str);
let binaryStr = '';
for (let i = 0; i < bytes.length; i++) {
binaryStr += String.fromCharCode(bytes[i]);
}
return btoa(binaryStr);
} catch (e) {
console.error('Base64 encode error:', e);
return null;
}
}
/**
* 复制文本到剪贴板
* @description 尝试使用现代API或降级方案将文本复制到剪贴板
* @param {string} text - 要复制的文本
* @returns {Promise<boolean>} 复制是否成功
* @example
* await copyToClipboard('Hello World') // returns true
*/
async copyToClipboard(text) {
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (e) {
return this.fallbackCopy(text);
}
}
return this.fallbackCopy(text);
}
/**
* 降级复制方案
* @description 当现代复制API不可用时的备选复制方案
* @param {string} text - 要复制的文本
* @returns {boolean} 复制是否成功
* @private
*/
fallbackCopy(text) {
if (typeof GM_setClipboard !== 'undefined') {
try {
GM_setClipboard(text);
return true;
} catch (e) {
console.debug('GM_setClipboard failed:', e);
}
}
try {
// 注意: execCommand 已经被废弃,但作为降级方案仍然有用
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.cssText = 'position:fixed;opacity:0;';
document.body.appendChild(textarea);
if (navigator.userAgent.match(/ipad|iphone/i)) {
textarea.contentEditable = true;
textarea.readOnly = false;
const range = document.createRange();
range.selectNodeContents(textarea);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
textarea.setSelectionRange(0, 999999);
} else {
textarea.select();
}
// 使用 try-catch 包裹 execCommand 调用,以防将来完全移除
let success = false;
try {
// @ts-ignore - 忽略废弃警告
success = document.execCommand('copy');
} catch (copyError) {
console.debug('execCommand copy operation failed:', copyError);
}
document.body.removeChild(textarea);
return success;
} catch (e) {
console.debug('Fallback copy method failed:', e);
return false;
}
}
/**
* 恢复原始内容
* @description 将所有解码后的内容恢复为原始的Base64格式
* @fires showNotification 显示恢复结果通知
*/
restoreContent() {
document.querySelectorAll('.decoded-text').forEach((el) => {
const textNode = document.createTextNode(el.dataset.original);
el.parentNode.replaceChild(textNode, el);
});
this.originalContents.clear();
// 如果按钮存在,更新按钮状态
if (this.decodeBtn) {
this.decodeBtn.textContent = '解析本页 Base64';
this.decodeBtn.dataset.mode = 'decode';
}
this.showNotification('已恢复原始内容', 'success');
// 只有当按钮可见时才隐藏菜单
if (!this.config.hideButton && this.menu) {
this.menu.style.display = 'none';
}
// 操作完成后更新菜单命令
setTimeout(updateMenuCommands, 100);
}
/**
* 重置插件状态
* @description 重置所有状态变量并在必要时恢复原始内容
* 如果启用了自动解码,则在路由变化后自动解析页面
* @fires restoreContent 如果当前处于restore模式则触发内容恢复
* @fires handleDecode 如果启用了自动解码则触发自动解码
*/
resetState() {
// 检查是否需要恢复内容
let needRestore = false;
// 如果按钮存在,检查按钮状态
if (this.decodeBtn && this.decodeBtn.dataset.mode === 'restore') {
needRestore = true;
} else {
// 如果按钮不存在,检查页面上是否有解码后的内容
needRestore = document.querySelectorAll('.decoded-text').length > 0;
}
if (needRestore) {
this.restoreContent();
}
// 如果启用了自动解码,则在路由变化后自动解析页面
if (this.config.autoDecode) {
// 使用延时确保页面内容已更新
setTimeout(() => this.handleDecode(), 500);
}
}
/**
* 为通知添加动画效果
* @param {HTMLElement} notification - 通知元素
*/
animateNotification(notification) {
const currentTransform = getComputedStyle(notification).transform;
notification.style.transform = currentTransform;
notification.style.transition = 'all 0.3s ease-out';
notification.style.transform = 'translateY(-100%)';
}
/**
* 处理通知淡出效果
* @description 为通知添加淡出效果并处理相关动画
* @param {HTMLElement} notification - 要处理的通知元素
* @fires animateNotification 触发其他通知的位置调整动画
*/
handleNotificationFadeOut(notification) {
notification.classList.add('fade-out');
const index = this.notifications.indexOf(notification);
this.notifications.slice(0, index).forEach((prev) => {
if (prev.parentNode) {
prev.style.transform = 'translateY(-100%)';
}
});
}
/**
* 清理通知容器
* @description 移除所有通知元素和相关事件监听器
* @fires removeEventListener 移除所有通知相关的事件监听器
*/
cleanupNotificationContainer() {
// 清理通知相关的事件监听器
this.notificationEventListeners.forEach(({ element, event, handler }) => {
element.removeEventListener(event, handler);
});
this.notificationEventListeners = [];
// 移除所有通知元素
while (this.notificationContainer.firstChild) {
this.notificationContainer.firstChild.remove();
}
this.notificationContainer.remove();
this.notificationContainer = null;
}
/**
* 处理通知过渡结束事件
* @description 处理通知元素的过渡动画结束后的清理工作
* @param {TransitionEvent} e - 过渡事件对象
* @fires animateNotification 触发其他通知的位置调整
*/
handleNotificationTransitionEnd(e) {
if (
e.propertyName === 'opacity' &&
e.target.classList.contains('fade-out')
) {
const notification = e.target;
const index = this.notifications.indexOf(notification);
this.notifications.forEach((notif, i) => {
if (i > index && notif.parentNode) {
this.animateNotification(notif);
}
});
if (index > -1) {
this.notifications.splice(index, 1);
notification.remove();
}
if (this.notifications.length === 0) {
this.cleanupNotificationContainer();
}
}
}
/**
* 显示通知消息
* @description 创建并显示一个通知消息,包含自动消失功能
* @param {string} text - 通知文本内容
* @param {string} type - 通知类型 ('success'|'error'|'info')
* @fires handleNotificationFadeOut 触发通知淡出效果
* @example
* showNotification('操作成功', 'success')
*/
showNotification(text, type) {
// 如果禁用了通知,则不显示
if (this.config && !this.config.showNotification) {
console.log(`[Base64 Helper] ${type}: ${text}`);
return;
}
if (!this.notificationContainer) {
this.notificationContainer = document.createElement('div');
this.notificationContainer.className = 'base64-notifications-container';
document.body.appendChild(this.notificationContainer);
const handler = (e) => this.handleNotificationTransitionEnd(e);
this.notificationContainer.addEventListener('transitionend', handler);
this.notificationEventListeners.push({
element: this.notificationContainer,
event: 'transitionend',
handler,
});
}
const notification = document.createElement('div');
notification.className = 'base64-notification';
notification.setAttribute('data-type', type);
notification.textContent = text;
this.notifications.push(notification);
this.notificationContainer.appendChild(notification);
setTimeout(() => {
if (notification.parentNode) {
this.handleNotificationFadeOut(notification);
}
}, 2000);
}
/**
* 销毁插件实例
* @description 清理所有资源,移除事件监听器,恢复原始状态
* @fires restoreContent 如果需要则恢复原始内容
* @fires removeEventListener 移除所有事件监听器
*/
destroy() {
// 清理所有事件监听器
this.eventListeners.forEach(({ element, event, handler, options }) => {
element.removeEventListener(event, handler, options);
});
this.eventListeners = [];
// 清理配置监听器
if (this.configListeners) {
Object.values(this.configListeners).forEach(listenerId => {
if (listenerId) {
storageManager.removeChangeListener(listenerId);
}
});
// 重置配置监听器
this.configListeners = {
showNotification: null,
hideButton: null,
autoDecode: null,
buttonPosition: null
};
}
// 清理定时器
if (this.resizeTimer) clearTimeout(this.resizeTimer);
if (this.routeTimer) clearTimeout(this.routeTimer);
// 清理通知相关资源
if (this.notificationContainer) {
this.cleanupNotificationContainer();
}
this.notifications = [];
// 恢复原始的 history 方法
if (this.originalPushState) history.pushState = this.originalPushState;
if (this.originalReplaceState)
history.replaceState = this.originalReplaceState;
// 恢复原始状态
if (this.decodeBtn?.dataset.mode === 'restore') {
this.restoreContent();
}
// 移除 DOM 元素
if (this.container) {
this.container.remove();
}
// 清理缓存
if (this.base64Cache) {
this.base64Cache.clear();
}
// 清理引用
this.shadowRoot = null;
this.mainBtn = null;
this.menu = null;
this.decodeBtn = null;
this.encodeBtn = null;
this.container = null;
this.originalContents.clear();
this.originalContents = null;
this.isDragging = false;
this.hasMoved = false;
this.menuVisible = false;
this.base64Cache = null;
this.configListeners = null;
}
}
// 确保只初始化一次
if (window.__base64HelperInstance) {
return;
}
// 只在主窗口中初始化
if (window.top === window.self) {
initStyles();
window.__base64HelperInstance = new Base64Helper();
// 注册(不可用)油猴菜单命令
registerMenuCommands();
}
// 使用 { once: true } 确保事件监听器只添加一次
window.addEventListener(
'unload',
() => {
if (window.__base64HelperInstance) {
window.__base64HelperInstance.destroy();
delete window.__base64HelperInstance;
}
},
{ once: true }
);
})();