// ==UserScript==
// @name 表情符号助手 Pro (Emoji Helper Pro)
// @namespace https://github.com/TechnologyStar/Emperor-Qin-Shi-Huang-Expression-Pack-Assistant
// @version 1.2.0
// @description 终极表情助手
// @author TechnologyStar
// @match *://*/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @connect api.giphy.com
// @connect tenor.googleapis.com
// @connect media.tenor.com
// @connect cdnjs.cloudflare.com
// @connect cdn.jsdelivr.net
// @connect unpkg.com
// @downloadURL https://update.greasyfork.icu/scripts/545019/%E8%A1%A8%E6%83%85%E7%AC%A6%E5%8F%B7%E5%8A%A9%E6%89%8B%20Pro%20%28Emoji%20Helper%20Pro%29.user.js
// @updateURL https://update.greasyfork.icu/scripts/545019/%E8%A1%A8%E6%83%85%E7%AC%A6%E5%8F%B7%E5%8A%A9%E6%89%8B%20Pro%20%28Emoji%20Helper%20Pro%29.meta.js
// ==/UserScript==
(function() {
// 网站白名单检测 - 添加这部分代码
const allowedSites = [
'github.com',
'linux.do',
'reddit.com'
// 在这里添加你想要启用的网站域名
];
const currentHost = window.location.hostname;
const isAllowed = allowedSites.some(site =>
currentHost === site || currentHost.endsWith('.' + site)
);
if (!isAllowed) {
console.log('表情助手:当前网站不在允许列表中');
return; // 退出脚本执行
}
// 网站检测结束
'use strict';
// 防止在iframe中重复执行
if (window.top !== window.self) return;
// 防止重复加载
if (window.EmojiHelperProLoaded) {
console.warn('EmojiHelper Pro 已加载,跳过重复初始化');
return;
}
window.EmojiHelperProLoaded = true;
// 🚀 详细日志系统
const Logger = {
levels: {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
TRACE: 4
},
categories: {
STORAGE: { name: 'Storage', color: '#4CAF50', emoji: '💾' },
CONFIG: { name: 'Config', color: '#2196F3', emoji: '⚙️' },
CLOUD: { name: 'CloudData', color: '#FF9800', emoji: '☁️' },
SEARCH: { name: 'Search', color: '#9C27B0', emoji: '🔍' },
UI: { name: 'UI', color: '#00BCD4', emoji: '🎨' },
EVENT: { name: 'Event', color: '#FF5722', emoji: '🎯' },
CACHE: { name: 'Cache', color: '#795548', emoji: '🗂️' },
UPDATE: { name: 'Update', color: '#607D8B', emoji: '🔄' },
INIT: { name: 'Init', color: '#E91E63', emoji: '🚀' },
ERROR: { name: 'Error', color: '#F44336', emoji: '❌' }
},
currentLevel: 3,
history: [],
maxHistorySize: 500,
_log(level, category, message, data = null) {
if (level > this.currentLevel) return;
const timestamp = new Date().toISOString();
const cat = this.categories[category] || this.categories.INFO;
const levelNames = ['ERROR', 'WARN', 'INFO', 'DEBUG', 'TRACE'];
const levelName = levelNames[level];
const logEntry = {
timestamp,
level: levelName,
category: cat.name,
message,
data: data ? this._safeClone(data) : null
};
this.history.push(logEntry);
if (this.history.length > this.maxHistorySize) {
this.history.shift();
}
const consoleMessage = `%c${cat.emoji} [${cat.name}] %c${message}`;
const styles = [
`color: ${cat.color}; font-weight: bold;`,
'color: inherit; font-weight: normal;'
];
switch(level) {
case this.levels.ERROR:
console.error(consoleMessage, ...styles, data);
break;
case this.levels.WARN:
console.warn(consoleMessage, ...styles, data);
break;
case this.levels.INFO:
console.info(consoleMessage, ...styles, data);
break;
default:
console.log(consoleMessage, ...styles, data);
break;
}
},
_safeClone(obj) {
try {
return JSON.parse(JSON.stringify(obj));
} catch {
return String(obj);
}
},
error(category, message, data) { this._log(this.levels.ERROR, category, message, data); },
warn(category, message, data) { this._log(this.levels.WARN, category, message, data); },
info(category, message, data) { this._log(this.levels.INFO, category, message, data); },
debug(category, message, data) { this._log(this.levels.DEBUG, category, message, data); },
trace(category, message, data) { this._log(this.levels.TRACE, category, message, data); },
setLevel(level) {
this.currentLevel = typeof level === 'string' ? this.levels[level.toUpperCase()] : level;
this.info('CONFIG', `日志级别设置为: ${Object.keys(this.levels)[this.currentLevel]}`);
},
getHistory(category = null, level = null) {
let filtered = this.history;
if (category) {
filtered = filtered.filter(log => log.category === category);
}
if (level) {
const levelValue = typeof level === 'string' ? this.levels[level.toUpperCase()] : level;
filtered = filtered.filter(log => this.levels[log.level] === levelValue);
}
return filtered;
},
clearHistory() {
const count = this.history.length;
this.history = [];
this.info('CONFIG', `清理了 ${count} 条日志记录`);
},
exportLogs() {
try {
const logs = JSON.stringify(this.history, null, 2);
const blob = new Blob([logs], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `emoji-helper-logs-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.info('CONFIG', '日志已导出');
} catch (error) {
this.error('CONFIG', '日志导出失败', error);
}
}
};
// 🔥 极简存储管理器 - 三重保险
const Storage = {
prefix: 'EmojiHelper_',
memCache: new Map(),
get(key, defaultVal) {
const fullKey = this.prefix + key;
// 先检查内存缓存
if (this.memCache.has(key)) {
Logger.trace('STORAGE', `内存缓存读取 ${key}`, this.memCache.get(key));
return this.memCache.get(key);
}
try {
const val = GM_getValue(fullKey);
if (val !== undefined) {
this.memCache.set(key, val);
Logger.trace('STORAGE', `GM读取 ${key}`, val);
return val;
}
} catch(e) {
Logger.warn('STORAGE', `GM读取失败: ${key}`, e.message);
}
try {
const val = localStorage.getItem(fullKey);
if (val !== null) {
const parsed = JSON.parse(val);
this.memCache.set(key, parsed);
Logger.trace('STORAGE', `localStorage读取 ${key}`, parsed);
return parsed;
}
} catch(e) {
Logger.warn('STORAGE', `localStorage读取失败: ${key}`, e.message);
}
Logger.debug('STORAGE', `使用默认值 ${key}`, defaultVal);
return defaultVal;
},
set(key, value) {
const fullKey = this.prefix + key;
let saved = false;
// 更新内存缓存
this.memCache.set(key, value);
try {
GM_setValue(fullKey, value);
if (GM_getValue(fullKey) === value) {
Logger.trace('STORAGE', `GM保存成功 ${key}`, value);
saved = true;
}
} catch(e) {
Logger.warn('STORAGE', `GM保存失败: ${key}`, e.message);
}
try {
localStorage.setItem(fullKey, JSON.stringify(value));
if (!saved) {
Logger.trace('STORAGE', `localStorage保存 ${key}`, value);
}
} catch(e) {
Logger.warn('STORAGE', `localStorage保存失败: ${key}`, e.message);
}
return true;
},
clearAll() {
const keys = [];
// 清理GM存储
try {
// 获取所有GM存储的键
const gmKeys = [];
for (let i = 0; i < 200; i++) {
const key = this.prefix + i;
if (GM_getValue(key) !== undefined) {
GM_setValue(key, undefined);
gmKeys.push(key);
}
}
keys.push(...gmKeys);
} catch(e) {
Logger.warn('STORAGE', 'GM清理失败', e.message);
}
// 清理localStorage
try {
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key && key.startsWith(this.prefix)) {
localStorage.removeItem(key);
keys.push(key);
}
}
} catch(e) {
Logger.warn('STORAGE', 'localStorage清理失败', e.message);
}
// 清理内存缓存
this.memCache.clear();
Logger.info('STORAGE', `清理了 ${keys.length} 个存储项`);
return keys.length;
}
};
// 🎯 配置管理器
const Config = {
defaults: {
lang: 'zh-CN',
theme: 'light',
autoInsert: true,
gifSize: 'medium',
searchEngine: 'giphy',
dataVersion: '1.0',
autoUpdate: true,
lastUpdateCheck: 0,
showFloatingButton: true,
panelPosition: { x: 20, y: 86 },
settingsPanelPosition: { x: 450, y: 86 },
editorPosition: { x: 'center', y: 'center' },
logLevel: 'WARN',
customUpdateUrl: 'https://raw.githubusercontent.com/TechnologyStar/Emperor-Qin-Shi-Huang-Expression-Pack-Assistant/refs/heads/main/neo.json',
enableDetailedLogs: true,
cacheSize: 100
},
cache: {},
init() {
Object.keys(this.defaults).forEach(key => {
this.cache[key] = Storage.get(key, this.defaults[key]);
});
Logger.setLevel(this.cache.logLevel);
Logger.info('CONFIG', '配置初始化完成', this.cache);
},
get(key) {
return this.cache[key];
},
set(key, value) {
const oldValue = this.cache[key];
if (oldValue === value) return false;
this.cache[key] = value;
Storage.set(key, value);
Logger.debug('CONFIG', `配置更新 ${key}: ${oldValue} -> ${value}`);
this.onConfigChange(key, value, oldValue);
return true;
},
onConfigChange(key, newValue, oldValue) {
try {
switch(key) {
case 'theme':
applyTheme();
break;
case 'lang':
updateAllText();
break;
case 'gifSize':
refreshCurrentView();
break;
case 'searchEngine':
clearGifCache();
break;
case 'showFloatingButton':
updateFloatingButtonVisibility();
break;
case 'panelPosition':
updatePanelPosition();
break;
case 'settingsPanelPosition':
updateSettingsPanelPosition();
break;
case 'logLevel':
Logger.setLevel(newValue);
break;
case 'customUpdateUrl':
Logger.info('CONFIG', '更新源地址已修改', newValue);
break;
}
} catch (error) {
Logger.error('CONFIG', '配置变更处理失败', { key, newValue, error });
}
},
reset() {
Logger.info('CONFIG', '重置所有配置');
Object.keys(this.defaults).forEach(key => {
this.set(key, this.defaults[key]);
});
showMessage('设置已重置');
}
};
// 🗂️ 缓存管理器
const CacheManager = {
cache: new Map(),
set(key, value, category = 'default') {
const cacheKey = `${category}:${key}`;
const cacheEntry = {
value,
timestamp: Date.now(),
category
};
this.cache.set(cacheKey, cacheEntry);
Logger.trace('CACHE', `缓存设置: ${cacheKey}`, value);
this.checkCacheLimit();
},
get(key, category = 'default') {
const cacheKey = `${category}:${key}`;
const entry = this.cache.get(cacheKey);
if (entry) {
Logger.trace('CACHE', `缓存命中: ${cacheKey}`);
return entry.value;
}
Logger.trace('CACHE', `缓存未命中: ${cacheKey}`);
return null;
},
has(key, category = 'default') {
const cacheKey = `${category}:${key}`;
return this.cache.has(cacheKey);
},
delete(key, category = 'default') {
const cacheKey = `${category}:${key}`;
const deleted = this.cache.delete(cacheKey);
if (deleted) {
Logger.debug('CACHE', `缓存删除: ${cacheKey}`);
}
return deleted;
},
clear(category = null) {
if (category) {
const keysToDelete = [];
for (const [key, entry] of this.cache.entries()) {
if (entry.category === category) {
keysToDelete.push(key);
}
}
keysToDelete.forEach(key => this.cache.delete(key));
Logger.info('CACHE', `清理分类缓存: ${category}, 删除 ${keysToDelete.length} 项`);
} else {
const size = this.cache.size;
this.cache.clear();
Logger.info('CACHE', `清理所有缓存, 删除 ${size} 项`);
}
},
checkCacheLimit() {
const limit = Config.get('cacheSize');
if (this.cache.size > limit) {
const entries = Array.from(this.cache.entries());
entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
const deleteCount = this.cache.size - limit + 10;
for (let i = 0; i < deleteCount && i < entries.length; i++) {
this.cache.delete(entries[i][0]);
}
Logger.debug('CACHE', `缓存大小超限,删除了 ${deleteCount} 个最旧条目`);
}
},
getStats() {
const stats = {
totalSize: this.cache.size,
categories: {}
};
for (const [key, entry] of this.cache.entries()) {
if (!stats.categories[entry.category]) {
stats.categories[entry.category] = 0;
}
stats.categories[entry.category]++;
}
return stats;
}
};
// 🌐 多语言
const i18n = {
'zh-CN': {
title: '表情助手',
settings: '设置',
search: '搜索表情、GIF...',
searchBtn: '搜索',
categories: {
all: '全部',
custom: '我的GIF',
smileys: '表情符号',
webGif: '网络GIF'
},
settingsPanel: {
title: '设置面板',
language: '界面语言',
theme: '主题',
autoInsert: '选择后自动关闭面板',
gifSize: 'GIF显示尺寸',
searchEngine: 'GIF搜索引擎',
close: '关闭',
reset: '重置设置',
dataVersion: '数据版本',
autoUpdate: '自动更新数据',
updateNow: '立即更新',
lastUpdate: '上次更新',
showFloatingButton: '显示浮动按钮',
logLevel: '日志级别',
customUpdateUrl: '自定义更新源',
enableDetailedLogs: '启用详细日志',
cacheSize: '缓存大小限制',
clearCache: '清理缓存',
clearAllData: '清理所有数据',
exportLogs: '导出日志',
advanced: '高级设置'
},
textEditor: {
title: '文字编辑器',
addText: '添加文字',
text: '文字内容',
textPlaceholder: '输入你想添加的文字...',
fontSize: '字体大小',
fontFamily: '字体类型',
textColor: '文字颜色',
position: '文字位置',
positions: {
top: '顶部',
center: '居中',
bottom: '底部'
},
generate: '复制图片',
download: '下载',
close: '关闭',
dragHint: '可拖拽到任意位置使用',
copyHint: '右键复制图片也可使用'
},
themes: {
light: '浅色',
dark: '深色'
},
sizes: {
small: '小',
medium: '中',
large: '大'
},
logLevels: {
ERROR: '错误',
WARN: '警告',
INFO: '信息',
DEBUG: '调试',
TRACE: '追踪'
},
messages: {
copied: '已复制到剪贴板!',
noResults: '没有找到相关内容',
searching: '搜索中...',
settingsSaved: '设置已保存!',
settingsReset: '设置已重置!',
searchHint: '输入关键词搜索GIF',
apiError: '网络搜索失败,请稍后重试',
updateSuccess: '数据更新成功!',
updateFailed: '数据更新失败',
updateChecking: '检查更新中...',
noUpdate: '已是最新版本',
imageGenerated: '图片生成成功!可拖拽或右键复制使用',
imageError: '图片生成失败',
cacheCleared: '缓存已清理',
dataCleared: '所有数据已清理',
logsExported: '日志已导出',
invalidUpdateUrl: '更新源地址无效'
}
},
'en': {
title: 'Emoji Helper',
settings: 'Settings',
search: 'Search emoji, GIF...',
searchBtn: 'Search',
categories: {
all: 'All',
custom: 'My GIFs',
smileys: 'Emojis',
webGif: 'Web GIFs'
},
settingsPanel: {
title: 'Settings Panel',
language: 'Language',
theme: 'Theme',
autoInsert: 'Auto close after selection',
gifSize: 'GIF display size',
searchEngine: 'GIF search engine',
close: 'Close',
reset: 'Reset Settings',
dataVersion: 'Data Version',
autoUpdate: 'Auto Update Data',
updateNow: 'Update Now',
lastUpdate: 'Last Update',
showFloatingButton: 'Show Floating Button',
logLevel: 'Log Level',
customUpdateUrl: 'Custom Update URL',
enableDetailedLogs: 'Enable Detailed Logs',
cacheSize: 'Cache Size Limit',
clearCache: 'Clear Cache',
clearAllData: 'Clear All Data',
exportLogs: 'Export Logs',
advanced: 'Advanced Settings'
},
textEditor: {
title: 'Text Editor',
addText: 'Add Text',
text: 'Text Content',
textPlaceholder: 'Enter text to add...',
fontSize: 'Font Size',
fontFamily: 'Font Family',
textColor: 'Text Color',
position: 'Text Position',
positions: {
top: 'Top',
center: 'Center',
bottom: 'Bottom'
},
generate: 'Copy Image',
download: 'Download',
close: 'Close',
dragHint: 'Draggable to any position',
copyHint: 'Right-click copy image also works'
},
themes: {
light: 'Light',
dark: 'Dark'
},
sizes: {
small: 'Small',
medium: 'Medium',
large: 'Large'
},
logLevels: {
ERROR: 'Error',
WARN: 'Warning',
INFO: 'Info',
DEBUG: 'Debug',
TRACE: 'Trace'
},
messages: {
copied: 'Copied to clipboard!',
noResults: 'No results found',
searching: 'Searching...',
settingsSaved: 'Settings saved!',
settingsReset: 'Settings reset!',
searchHint: 'Enter keywords to search GIFs',
apiError: 'Network search failed, please try again',
updateSuccess: 'Data updated successfully!',
updateFailed: 'Data update failed',
updateChecking: 'Checking for updates...',
noUpdate: 'Already up to date',
imageGenerated: 'Image generated successfully! Draggable or right-click to copy',
imageError: 'Image generation failed',
cacheCleared: 'Cache cleared',
dataCleared: 'All data cleared',
logsExported: 'Logs exported',
invalidUpdateUrl: 'Invalid update URL'
}
}
};
const t = () => i18n[Config.get('lang')] || i18n['zh-CN'];
// 全局变量
let emojiPanel = null;
let settingsPanel = null;
let textEditorPanel = null;
let floatingButton = null;
let webGifCache = new Map();
let isSearching = false;
let searchRequestId = 0;
let currentEditingImage = null;
// 拖拽相关变量
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
let currentDragElement = null;
// 默认自定义GIF(本地备份)
let customGifs = [
{
name: 'cat-wave',
url: 'https://file.woodo.cn/upload/image/201910/25/c7eb21a4-7693-4836-b23a-5ab3c9e1813d.gif',
alt: '招手猫',
keywords: ['猫', '招手', 'cat', 'wave', 'hello', '你好', '嗨']
},
{
name: 'hello-cat',
url: 'https://c-ssl.duitang.com/uploads/item/202001/11/20200111042746_kmmjw.gif',
alt: '你好猫',
keywords: ['猫', '你好', 'cat', 'hello', 'hi', '问候', '打招呼']
},
{
name: 'thumbs-up',
url: 'https://media.giphy.com/media/111ebonMs90YLu/giphy.gif',
alt: '点赞',
keywords: ['点赞', '赞', '好', 'thumbs', 'up', 'good', 'nice', '棒']
},
{
name: 'happy-dance',
url: 'https://media.giphy.com/media/l0MYt5jPR6QX5pnqM/giphy.gif',
alt: '开心舞蹈',
keywords: ['开心', '舞蹈', '高兴', 'happy', 'dance', 'excited', 'party']
}
];
// 表情符号
const defaultEmojis = ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘', '😗', '😙', '😚', '😋', '😛', '😝', '😜', '🤪', '🤨', '🧐', '🤓', '😎', '🤩', '🥳', '😏', '😒', '😞', '😔', '😟', '😕', '🙁', '☹️', '😣', '😖', '😫', '😩', '🥺', '😢', '😭', '😤', '😠', '😡', '🤬', '🤯', '😳', '🥵', '🥶', '😱', '😨', '😰', '😥', '😓', '🤗', '🤔', '🤭', '🤫', '🤥', '😶', '😐', '😑', '😬', '🙄', '😯', '😦', '😧', '😮', '😲', '🥱', '😴', '🤤', '😪', '😵', '🤐', '🥴', '🤢', '🤮', '🤧', '😷', '🤒', '🤕', '🤑', '🤠', '😈', '👿', '👹', '👺', '🤡', '💩', '👻', '💀', '☠️', '👽', '👾', '🤖', '🎃', '😺', '😸', '😹', '😻', '😼', '😽', '🙀', '😿', '😾'];
// 🔄 云数据更新管理器
const CloudDataManager = {
getUpdateUrl() {
return Config.get('customUpdateUrl');
},
validateUpdateUrl(url) {
try {
const urlObj = new URL(url);
return ['https:', 'http:'].includes(urlObj.protocol);
} catch {
return false;
}
},
async checkAndUpdate(forceUpdate = false) {
try {
Logger.info('UPDATE', '开始检查更新', { force: forceUpdate });
if (!forceUpdate) {
if (!Config.get('autoUpdate')) {
Logger.info('UPDATE', '自动更新已禁用');
return false;
}
const lastCheck = Config.get('lastUpdateCheck');
const now = Date.now();
if (now - lastCheck < 24 * 60 * 60 * 1000) {
Logger.debug('UPDATE', '距离上次检查不足24小时', {
lastCheck: new Date(lastCheck).toLocaleString(),
nextCheck: new Date(lastCheck + 24 * 60 * 60 * 1000).toLocaleString()
});
return false;
}
}
const updateUrl = this.getUpdateUrl();
if (!this.validateUpdateUrl(updateUrl)) {
Logger.error('UPDATE', '更新源地址无效', updateUrl);
if (forceUpdate) {
showMessage(t().messages.invalidUpdateUrl);
}
return false;
}
const cloudData = await this.fetchCloudData(updateUrl);
if (!cloudData) {
Logger.warn('UPDATE', '获取云数据失败');
return false;
}
const cloudVersion = cloudData.version || '1.0';
const localVersion = Config.get('dataVersion');
Logger.info('UPDATE', '版本比较', {
local: localVersion,
cloud: cloudVersion,
updateUrl
});
if (forceUpdate || this.isNewerVersion(cloudVersion, localVersion)) {
await this.updateLocalData(cloudData);
Config.set('dataVersion', cloudVersion);
Config.set('lastUpdateCheck', Date.now());
showMessage(t().messages.updateSuccess);
Logger.info('UPDATE', '数据更新成功', {
oldVersion: localVersion,
newVersion: cloudVersion,
gifCount: cloudData.customGifs?.length || 0
});
return true;
} else {
Config.set('lastUpdateCheck', Date.now());
if (forceUpdate) {
showMessage(t().messages.noUpdate);
}
Logger.info('UPDATE', '已是最新版本', { version: cloudVersion });
return false;
}
} catch (error) {
Logger.error('UPDATE', '更新失败', error);
if (forceUpdate) {
showMessage(t().messages.updateFailed);
}
return false;
}
},
async fetchCloudData(url) {
return new Promise((resolve, reject) => {
Logger.debug('UPDATE', '开始获取云数据', url);
const timeout = setTimeout(() => {
reject(new Error('请求超时'));
}, 15000);
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: 15000,
onload: (response) => {
clearTimeout(timeout);
try {
Logger.debug('UPDATE', '云数据响应状态', response.status);
if (response.status === 200) {
const data = JSON.parse(response.responseText);
Logger.info('UPDATE', '获取云数据成功', {
version: data.version,
gifCount: data.customGifs?.length || 0,
dataSize: response.responseText.length
});
resolve(data);
} else {
Logger.error('UPDATE', `HTTP错误: ${response.status}`);
reject(new Error(`HTTP ${response.status}`));
}
} catch (e) {
Logger.error('UPDATE', '解析响应数据失败', e);
reject(e);
}
},
onerror: (error) => {
clearTimeout(timeout);
Logger.error('UPDATE', '请求失败', error);
reject(error);
},
ontimeout: () => {
clearTimeout(timeout);
Logger.error('UPDATE', '请求超时');
reject(new Error('请求超时'));
}
});
});
},
isNewerVersion(cloudVersion, localVersion) {
const parseVersion = (v) => {
const cleaned = v.replace(/^v/, '');
return cleaned.split('.').map(n => parseInt(n) || 0);
};
const cloud = parseVersion(cloudVersion);
const local = parseVersion(localVersion);
Logger.debug('UPDATE', '版本解析', {
cloudParsed: cloud,
localParsed: local
});
for (let i = 0; i < Math.max(cloud.length, local.length); i++) {
const c = cloud[i] || 0;
const l = local[i] || 0;
if (c > l) {
Logger.debug('UPDATE', '发现新版本', { position: i, cloud: c, local: l });
return true;
}
if (c < l) {
Logger.debug('UPDATE', '云端版本较旧', { position: i, cloud: c, local: l });
return false;
}
}
Logger.debug('UPDATE', '版本相同');
return false;
},
async updateLocalData(cloudData) {
Logger.info('UPDATE', '开始更新本地数据', cloudData);
if (cloudData.customGifs && Array.isArray(cloudData.customGifs)) {
const oldCount = customGifs.length;
customGifs = cloudData.customGifs;
Storage.set('customGifs', customGifs);
CacheManager.clear('gif');
CacheManager.clear('search');
refreshCurrentView();
Logger.info('UPDATE', '本地数据已更新', {
oldCount,
newCount: customGifs.length,
added: customGifs.length - oldCount
});
} else {
Logger.warn('UPDATE', '云数据格式无效', cloudData);
}
},
async manualUpdate() {
showMessage(t().messages.updateChecking);
Logger.info('UPDATE', '手动检查更新');
return await this.checkAndUpdate(true);
}
};
/* === EH 工具函数 BEGIN === */
// 外部库地址
const EH_GIF_JS_CANDIDATES = [
'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.min.js',
'https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.min.js',
'https://unpkg.com/gif.js@0.2.0/dist/gif.min.js'
];
const EH_GIF_WORKER_CANDIDATES = [
'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.min.js',
'https://cdn.jsdelivr.net/npm/gif.js@0.2.0/dist/gif.worker.min.js',
'https://unpkg.com/gif.js@0.2.0/dist/gif.worker.min.js'
];
const EH_GIFUCT_JS_CANDIDATES = [
'https://cdn.jsdelivr.net/npm/gifuct-js@1.0.2/dist/gifuct.min.js',
'https://unpkg.com/gifuct-js@1.0.2/dist/gifuct.min.js'
];
// 简单日志别名
const EH_LOG = { i: (...a)=>console.info('[EH]',...a), w:(...a)=>console.warn('[EH]',...a), e:(...a)=>console.error('[EH]',...a) };
function eh_withTimeout(promise, ms = 3000) {
return Promise.race([
promise,
new Promise(resolve => setTimeout(() => resolve('__EH_TIMEOUT__'), ms))
]);
}
// 动态载入脚本(一次)
async function eh_loadScriptOnce(urlOrList){
const urls = Array.isArray(urlOrList) ? urlOrList : [urlOrList];
for (const url of urls) {
if (window.__eh_loadedLibs && window.__eh_loadedLibs[url]) return;
try {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = url;
s.crossOrigin = 'anonymous';
s.onload = () => {
window.__eh_loadedLibs = window.__eh_loadedLibs || {};
window.__eh_loadedLibs[url] = true;
resolve();
};
s.onerror = (err) => { EH_LOG.w('load lib fail', url, err); reject(err); };
document.head.appendChild(s);
});
return; // 某个候选加载成功,直接结束
} catch (e) {
// 该源失败,继续尝试下一个
}
}
throw new Error('All CDN sources failed');
}
// 确保所需库已经加载
async function eh_ensureLibs(){
if (!window.GIF) await eh_loadScriptOnce(EH_GIF_JS_CANDIDATES);
if (!window.gifuct) await eh_loadScriptOnce(EH_GIFUCT_JS_CANDIDATES);
try {
if (window.GIF) {
for (const url of EH_GIF_WORKER_CANDIDATES) {
try { window.GIF.prototype.workerScript = url; break; } catch(e) {}
}
}
} catch(e){ EH_LOG.w('set workerScript fail', e); }
}
// GM 跨域获取 ArrayBuffer(用于绕过 CORS)
function eh_gmFetchArrayBuffer(url, timeout=20000){
return new Promise((resolve, reject) => {
try {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'arraybuffer',
timeout,
onload(res){ if (res.status >= 200 && res.status < 300) resolve(res.response); else reject(new Error('HTTP ' + res.status)); },
onerror(err){ reject(err); },
ontimeout(){ reject(new Error('timeout')); }
});
} catch(e) { reject(e); }
});
}
// ArrayBuffer -> objectURL
function eh_arrayBufferToObjectURL(ab, mime='image/gif'){
const blob = new Blob([ab], { type: mime });
return URL.createObjectURL(blob);
}
// 尝试用 GM 请求获取资源并返回 objectURL,失败回退原 url
async function eh_loadImageObjectURL(url){
try {
const ab = await eh_gmFetchArrayBuffer(url);
let mime = 'image/gif';
if (/\.jpe?g($|\?)/i.test(url)) mime = 'image/jpeg';
if (/\.png($|\?)/i.test(url)) mime = 'image/png';
if (/\.webp($|\?)/i.test(url)) mime = 'image/webp';
return eh_arrayBufferToObjectURL(ab, mime);
} catch (err) {
EH_LOG.w('GM fetch failed, fallback to direct URL', err);
return url;
}
}
// 用 gifuct-js 解析 GIF 帧
function eh_parseGifFramesFromArrayBuffer(ab){
const parsed = window.gifuct.parseGIF(ab);
const frames = window.gifuct.decompressFrames(parsed, true);
return frames;
}
// 将 gifuct-js 的帧合成为全帧 Canvas 列表(简单处理 disposalType==2)
function eh_framesToCanvases(frames){
const W = frames[0].dims.width;
const H = frames[0].dims.height;
const base = document.createElement('canvas'); base.width = W; base.height = H;
const ctx = base.getContext('2d');
ctx.clearRect(0,0,W,H);
const out = [];
frames.forEach(frame => {
const { left, top, width: w, height: h } = frame.dims;
try {
const patch = new ImageData(new Uint8ClampedArray(frame.patch), w, h);
ctx.putImageData(patch, left, top);
} catch(e) {
EH_LOG.w('putImageData failed', e);
}
const c = document.createElement('canvas'); c.width = W; c.height = H;
c.getContext('2d').drawImage(base, 0, 0);
out.push(c);
if (frame.disposalType === 2) {
ctx.clearRect(left, top, w, h);
}
});
return out;
}
function eh_scaleFrameCanvas(canvas, scale, maxW = 480, maxH = 480){
const w = Math.min(Math.round(canvas.width * scale), maxW);
const h = Math.min(Math.round(canvas.height * scale), maxH);
const c = document.createElement('canvas'); c.width = w; c.height = h;
c.getContext('2d').drawImage(canvas, 0, 0, w, h);
return c;
}
function eh_drawTextOnCanvas(canvas, text, opts = { fontSize: 36, fontFamily: 'Arial, sans-serif', color: '#fff', stroke: '#000', position: 'bottom' }){
const ctx = canvas.getContext('2d');
ctx.save();
const scaleRef = Math.max(1, canvas.width / 400);
const fs = Math.round(opts.fontSize * scaleRef);
ctx.font = `bold ${fs}px ${opts.fontFamily}`;
ctx.textAlign = 'center';
ctx.fillStyle = opts.color || '#fff';
ctx.strokeStyle = opts.stroke || '#000';
ctx.lineWidth = Math.max(2, fs / 18);
let x = Math.round(canvas.width / 2);
let y;
if (opts.position === 'top') y = fs + 10;
else if (opts.position === 'center') y = Math.round(canvas.height / 2 + fs / 3);
else y = canvas.height - 10;
ctx.strokeText(text, x, y);
ctx.fillText(text, x, y);
ctx.restore();
}
// 使用 gif.js 编码 frames (canvas[]) -> Blob
function eh_encodeWithGifJs(frames, delays, { quality = 12, repeat = 0 } = {}){
return new Promise(async (resolve, reject) => {
await eh_ensureLibs();
try {
const gif = new GIF({ workers: 2, quality, repeat });
frames.forEach((c, i) => gif.addFrame(c, { delay: delays[i] || 100 }));
gif.on('finished', blob => resolve(blob));
gif.on('error', err => reject(err));
gif.render();
} catch (e) { reject(e); }
});
}
// 迭代尝试不同缩放比以控制输出大小(maxBytes,默认 5MB)
async function eh_encodeGifWithLimit(frames, delays, { maxBytes = 5*1024*1024, quality = 12, repeat = 0, maxWidth = 480, maxHeight = 480 } = {}){
let scale = 1.0;
let lastBlob = null;
for (let i=0;i<6;i++){
const scaled = frames.map(f => eh_scaleFrameCanvas(f, scale, maxWidth, maxHeight));
const blob = await eh_encodeWithGifJs(scaled, delays, { quality, repeat });
lastBlob = blob;
EH_LOG.i('encode try', i, 'scale', scale, 'size', blob.size);
if (blob.size <= maxBytes) return blob;
scale *= 0.8;
}
return lastBlob;
}
// 复制 Blob 到剪贴板(优先 Clipboard API)
async function eh_copyBlobToClipboard(blob, { allowDownload = true } = {}) {
// 1) 原生写入对应 MIME(若支持 image/gif 就保持动图)
try {
if (navigator.clipboard && navigator.clipboard.write && window.ClipboardItem) {
const item = new ClipboardItem({ [blob.type]: blob });
await navigator.clipboard.write([item]);
return true;
}
} catch (e) {
EH_LOG.w('clipboard write failed', e);
}
// 2) ✂️ 删除原逻辑(写入 blob: 文本URL)
// 3) 仍不行:允许则下载兜底,至少保证得到动图文件
if (!allowDownload) return false;
try {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'emoji-' + Date.now() + (blob.type.includes('gif') ? '.gif' : '.png');
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 3000);
return true;
} catch (e3) {
EH_LOG.e('fallback download failed', e3);
return false;
}
}
/* 主流程:给 imageUrl(gif 或 静态)加文字并返回 Blob */
async function addTextToImageOrGifAndExport(imageUrl, text, options = { fontSize: 36, fontFamily: 'Arial', color: '#fff', position: 'bottom' }){
await eh_ensureLibs();
const objectUrl = await eh_loadImageObjectURL(imageUrl);
const isGif = /\.gif($|\?)/i.test(imageUrl) || (objectUrl && objectUrl.startsWith('blob:') && /\.gif($|\?)/i.test(imageUrl));
if (isGif) {
let ab;
try { ab = await eh_gmFetchArrayBuffer(imageUrl); }
catch(e) { const resp = await fetch(objectUrl); ab = await resp.arrayBuffer(); }
const frames = eh_parseGifFramesFromArrayBuffer(ab);
const canvases = eh_framesToCanvases(frames);
const delays = frames.map(f => (f.delay || 10) * 10);
canvases.forEach(c => eh_drawTextOnCanvas(c, text, options));
const blob = await eh_encodeGifWithLimit(canvases, delays, { maxBytes: 5*1024*1024, quality: 12, maxWidth: 480, maxHeight: 480 });
return blob;
} else {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = objectUrl || imageUrl;
await new Promise((res, rej)=>{ img.onload = res; img.onerror = ()=>rej(new Error('image load fail')); });
const maxW = 1024, maxH = 1024;
let w = img.naturalWidth, h = img.naturalHeight;
const r = Math.min(1, Math.min(maxW / w, maxH / h));
w = Math.round(w * r); h = Math.round(h * r);
const c = document.createElement('canvas'); c.width = w; c.height = h;
const ctx = c.getContext('2d'); ctx.drawImage(img, 0, 0, w, h);
eh_drawTextOnCanvas(c, text, options);
const blob = await new Promise(resolve => c.toBlob(resolve, 'image/webp', 0.85));
if (blob && blob.size <= 5*1024*1024) return blob;
return await new Promise(resolve => c.toBlob(resolve, 'image/png', 0.95));
}
}
// 从 URL 拉取图像并转成 PNG Blob(走 GM_xmlhttpRequest,避免 CORS 污染)
async function eh_fetchBlobFromUrl(imageUrl) {
try {
// 先尝试用 GM 直接拿原始二进制并保留 MIME(GIF 动图不会丢帧)
const ab = await eh_gmFetchArrayBuffer(imageUrl);
let mime = 'application/octet-stream';
if (/\.gif($|\?)/i.test(imageUrl)) mime = 'image/gif';
else if (/\.png($|\?)/i.test(imageUrl)) mime = 'image/png';
else if (/\.jpe?g($|\?)/i.test(imageUrl)) mime = 'image/jpeg';
else if (/\.webp($|\?)/i.test(imageUrl)) mime = 'image/webp';
return new Blob([ab], { type: mime });
} catch (e) {
// 回退:静图转 PNG,GIF 仍尽量保持动画(多数浏览器直接 即可动)
const objectUrl = await eh_loadImageObjectURL(imageUrl);
const isGif = /\.gif($|\?)/i.test(imageUrl);
if (isGif) {
// 没有 GM 权限时,尽量把 blob: URL 的数据当作 GIF 交还
const resp = await fetch(objectUrl);
const buf = await resp.arrayBuffer();
URL.revokeObjectURL(objectUrl);
return new Blob([buf], { type: 'image/gif' });
} else {
const img = new Image();
img.src = objectUrl;
await img.decode();
const c = document.createElement('canvas');
c.width = img.naturalWidth;
c.height = img.naturalHeight;
c.getContext('2d').drawImage(img, 0, 0);
const pngBlob = await new Promise(res => c.toBlob(res, 'image/png', 0.95));
URL.revokeObjectURL(objectUrl);
return pngBlob;
}
}
}
// 模拟“复制图像”——始终以 PNG 写入剪贴板;失败不下载
async function copyImageLikeBrowser(imageUrl) {
const blob = await eh_fetchBlobFromUrl(imageUrl);
const isGif = /\.gif($|\?)/i.test(imageUrl) || (blob && blob.type === 'image/gif');
await eh_copyBlobToClipboard(blob, { allowDownload: isGif });
}
/* === EH 工具函数 END === */
// 🎨 文字编辑器
const TextEditor = {
fonts: [
'Arial, sans-serif',
'Helvetica, sans-serif',
'Georgia, serif',
'Times New Roman, serif',
'Courier New, monospace',
'Verdana, sans-serif',
'Impact, sans-serif',
'Comic Sans MS, cursive',
'Trebuchet MS, sans-serif',
'Arial Black, sans-serif',
'Microsoft YaHei, sans-serif',
'SimHei, sans-serif',
'SimSun, serif',
'KaiTi, serif'
],
open(imageUrl) {
currentEditingImage = imageUrl;
Logger.info('UI', '打开文字编辑器', imageUrl);
this.createEditor();
this.showEditor();
},
createEditor() {
if (textEditorPanel) {
textEditorPanel.remove();
Logger.debug('UI', '移除旧的编辑器面板');
}
const lang = t();
const panel = document.createElement('div');
panel.className = 'emoji-helper-text-editor';
panel.id = 'emoji-helper-text-editor';
panel.innerHTML = `