您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
为论坛网站添加表情选择器功能 (Add emoji picker functionality to forum websites)
当前为
// ==UserScript== // @name Linux do 表情扩展 (Emoji Extension) lite // @namespace https://github.com/stevessr/bug-v3 // @version 1.0.5 // @description 为论坛网站添加表情选择器功能 (Add emoji picker functionality to forum websites) // @author stevessr // @match https://linux.do/* // @match https://meta.discourse.org/* // @match https://*.discourse.org/* // @match http://localhost:5173/* // @grant none // @license MIT // @homepageURL https://github.com/stevessr/bug-v3 // @supportURL https://github.com/stevessr/bug-v3/issues // @run-at document-end // ==/UserScript== ;(function () { 'use strict' ;(function () { const __defProp = Object.defineProperty const __esmMin = (fn, res) => () => (fn && (res = fn((fn = 0))), res) const __export = all => { const target = {} for (const name in all) __defProp(target, name, { get: all[name], enumerable: true }) return target } async function fetchPackagedJSON() { try { if (typeof fetch === 'undefined') return null const res = await fetch('/assets/defaultEmojiGroups.json', { cache: 'no-cache' }) if (!res.ok) return null return await res.json() } catch (err) { return null } } async function loadDefaultEmojiGroups() { const packaged = await fetchPackagedJSON() if (packaged && Array.isArray(packaged.groups)) return packaged.groups return [] } const init_defaultEmojiGroups_loader = __esmMin(() => {}) function loadDataFromLocalStorage() { try { const groupsData = localStorage.getItem(STORAGE_KEY) let emojiGroups = [] if (groupsData) try { const parsed = JSON.parse(groupsData) if (Array.isArray(parsed) && parsed.length > 0) emojiGroups = parsed } catch (e) { console.warn('[Userscript] Failed to parse stored emoji groups:', e) } if (emojiGroups.length === 0) emojiGroups = [] const settingsData = localStorage.getItem(SETTINGS_KEY) let settings = { imageScale: 30, gridColumns: 4, outputFormat: 'markdown', forceMobileMode: false, defaultGroup: 'nachoneko', showSearchBar: true, enableFloatingPreview: true } if (settingsData) try { const parsed = JSON.parse(settingsData) if (parsed && typeof parsed === 'object') settings = { ...settings, ...parsed } } catch (e) { console.warn('[Userscript] Failed to parse stored settings:', e) } emojiGroups = emojiGroups.filter(g => g.id !== 'favorites') console.log('[Userscript] Loaded data from localStorage:', { groupsCount: emojiGroups.length, emojisCount: emojiGroups.reduce((acc, g) => acc + (g.emojis?.length || 0), 0), settings }) return { emojiGroups, settings } } catch (error) { console.error('[Userscript] Failed to load from localStorage:', error) console.error('[Userscript] Failed to load from localStorage:', error) return { emojiGroups: [], settings: { imageScale: 30, gridColumns: 4, outputFormat: 'markdown', forceMobileMode: false, defaultGroup: 'nachoneko', showSearchBar: true, enableFloatingPreview: true } } } } async function loadDataFromLocalStorageAsync() { try { const local = loadDataFromLocalStorage() if (local.emojiGroups && local.emojiGroups.length > 0) return local const remoteUrl = localStorage.getItem('emoji_extension_remote_config_url') if (remoteUrl && typeof remoteUrl === 'string' && remoteUrl.trim().length > 0) try { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), 5e3) const res = await fetch(remoteUrl, { signal: controller.signal }) clearTimeout(timeout) if (res && res.ok) { const json = await res.json() const groups = Array.isArray(json.emojiGroups) ? json.emojiGroups : Array.isArray(json.groups) ? json.groups : null const settings = json.settings && typeof json.settings === 'object' ? json.settings : local.settings if (groups && groups.length > 0) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(groups)) } catch (e) { console.warn( '[Userscript] Failed to persist fetched remote groups to localStorage', e ) } return { emojiGroups: groups.filter(g => g.id !== 'favorites'), settings } } } } catch (err) { console.warn('[Userscript] Failed to fetch remote default config:', err) } try { const runtime = await loadDefaultEmojiGroups() const source = runtime && runtime.length ? runtime : [] const filtered = JSON.parse(JSON.stringify(source)).filter(g => g.id !== 'favorites') try { localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered)) } catch (e) {} return { emojiGroups: filtered, settings: local.settings } } catch (e) { console.error('[Userscript] Failed to load default groups in async fallback:', e) return { emojiGroups: [], settings: local.settings } } } catch (error) { console.error('[Userscript] loadDataFromLocalStorageAsync failed:', error) return { emojiGroups: [], settings: { imageScale: 30, gridColumns: 4, outputFormat: 'markdown', forceMobileMode: false, defaultGroup: 'nachoneko', showSearchBar: true, enableFloatingPreview: true } } } } function saveDataToLocalStorage(data) { try { if (data.emojiGroups) localStorage.setItem(STORAGE_KEY, JSON.stringify(data.emojiGroups)) if (data.settings) localStorage.setItem(SETTINGS_KEY, JSON.stringify(data.settings)) } catch (error) { console.error('[Userscript] Failed to save to localStorage:', error) } } function addEmojiToUserscript(emojiData) { try { const data = loadDataFromLocalStorage() let userGroup = data.emojiGroups.find(g => g.id === 'user_added') if (!userGroup) { userGroup = { id: 'user_added', name: '用户添加', icon: '⭐', order: 999, emojis: [] } data.emojiGroups.push(userGroup) } if (!userGroup.emojis.some(e => e.url === emojiData.url || e.name === emojiData.name)) { userGroup.emojis.push({ packet: Date.now(), name: emojiData.name, url: emojiData.url }) saveDataToLocalStorage({ emojiGroups: data.emojiGroups }) console.log('[Userscript] Added emoji to user group:', emojiData.name) } else console.log('[Userscript] Emoji already exists:', emojiData.name) } catch (error) { console.error('[Userscript] Failed to add emoji:', error) } } function exportUserscriptData() { try { const data = loadDataFromLocalStorage() return JSON.stringify(data, null, 2) } catch (error) { console.error('[Userscript] Failed to export data:', error) return '' } } function importUserscriptData(jsonData) { try { const data = JSON.parse(jsonData) if (data.emojiGroups && Array.isArray(data.emojiGroups)) saveDataToLocalStorage({ emojiGroups: data.emojiGroups }) if (data.settings && typeof data.settings === 'object') saveDataToLocalStorage({ settings: data.settings }) console.log('[Userscript] Data imported successfully') return true } catch (error) { console.error('[Userscript] Failed to import data:', error) return false } } function syncFromManager() { try { const managerGroups = localStorage.getItem('emoji_extension_manager_groups') const managerSettings = localStorage.getItem('emoji_extension_manager_settings') let updated = false if (managerGroups) { const groups = JSON.parse(managerGroups) if (Array.isArray(groups)) { localStorage.setItem(STORAGE_KEY, JSON.stringify(groups)) updated = true } } if (managerSettings) { const settings = JSON.parse(managerSettings) if (typeof settings === 'object') { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)) updated = true } } if (updated) console.log('[Userscript] Synced data from manager') return updated } catch (error) { console.error('[Userscript] Failed to sync from manager:', error) return false } } function trackEmojiUsage(emojiName, emojiUrl) { try { const key = `${emojiName}|${emojiUrl}` const statsData = localStorage.getItem(USAGE_STATS_KEY) let stats = {} if (statsData) try { stats = JSON.parse(statsData) } catch (e) { console.warn('[Userscript] Failed to parse usage stats:', e) } if (!stats[key]) stats[key] = { count: 0, lastUsed: 0 } stats[key].count++ stats[key].lastUsed = Date.now() localStorage.setItem(USAGE_STATS_KEY, JSON.stringify(stats)) } catch (error) { console.error('[Userscript] Failed to track emoji usage:', error) } } function getPopularEmojis(limit = 20) { try { const statsData = localStorage.getItem(USAGE_STATS_KEY) if (!statsData) return [] const stats = JSON.parse(statsData) return Object.entries(stats) .map(([key, data]) => { const [name, url] = key.split('|') return { name, url, count: data.count, lastUsed: data.lastUsed } }) .sort((a, b) => b.count - a.count) .slice(0, limit) } catch (error) { console.error('[Userscript] Failed to get popular emojis:', error) return [] } } function clearEmojiUsageStats() { try { localStorage.removeItem(USAGE_STATS_KEY) console.log('[Userscript] Cleared emoji usage statistics') } catch (error) { console.error('[Userscript] Failed to clear usage stats:', error) } } let STORAGE_KEY, SETTINGS_KEY, USAGE_STATS_KEY const init_userscript_storage = __esmMin(() => { init_defaultEmojiGroups_loader() STORAGE_KEY = 'emoji_extension_userscript_data' SETTINGS_KEY = 'emoji_extension_userscript_settings' USAGE_STATS_KEY = 'emoji_extension_userscript_usage_stats' }) let userscriptState const init_state = __esmMin(() => { userscriptState = { emojiGroups: [], settings: { imageScale: 30, gridColumns: 4, outputFormat: 'markdown', forceMobileMode: false, defaultGroup: 'nachoneko', showSearchBar: true, enableFloatingPreview: true }, emojiUsageStats: {} } }) function createEl(tag, opts) { const el = document.createElement(tag) if (opts) { if (opts.width) el.style.width = opts.width if (opts.height) el.style.height = opts.height if (opts.className) el.className = opts.className if (opts.text) el.textContent = opts.text if (opts.placeholder && 'placeholder' in el) el.placeholder = opts.placeholder if (opts.type && 'type' in el) el.type = opts.type if (opts.value !== void 0 && 'value' in el) el.value = opts.value if (opts.style) el.style.cssText = opts.style if (opts.src && 'src' in el) el.src = opts.src if (opts.attrs) for (const k in opts.attrs) el.setAttribute(k, opts.attrs[k]) if (opts.dataset) for (const k in opts.dataset) el.dataset[k] = opts.dataset[k] if (opts.innerHTML) el.innerHTML = opts.innerHTML if (opts.title) el.title = opts.title if (opts.alt && 'alt' in el) el.alt = opts.alt } return el } const init_createEl = __esmMin(() => {}) init_createEl() init_state() init_userscript_storage() function notify(message, type = 'info', timeout = 4e3) { try { let container = document.getElementById('emoji-ext-toast-container') if (!container) { container = document.createElement('div') container.id = 'emoji-ext-toast-container' container.style.position = 'fixed' container.style.right = '12px' container.style.bottom = '12px' container.style.zIndex = '2147483647' container.style.display = 'flex' container.style.flexDirection = 'column' container.style.gap = '8px' document.body.appendChild(container) } const el = document.createElement('div') el.textContent = message el.style.padding = '8px 12px' el.style.borderRadius = '6px' el.style.boxShadow = '0 2px 8px rgba(0,0,0,0.12)' el.style.color = '#ffffff' el.style.fontSize = '13px' el.style.maxWidth = '320px' el.style.wordBreak = 'break-word' if (type === 'success') el.style.background = '#16a34a' else if (type === 'error') el.style.background = '#dc2626' else el.style.background = '#0369a1' container.appendChild(el) const id = setTimeout(() => { el.remove() clearTimeout(id) }, timeout) return () => { el.remove() clearTimeout(id) } } catch (e) { try { alert(message) } catch (_e) {} return () => {} } } async function postTimings(topicId, timings) { function readCsrfToken() { try { const meta = document.querySelector('meta[name="csrf-token"]') if (meta && meta.content) return meta.content const input = document.querySelector('input[name="authenticity_token"]') if (input && input.value) return input.value const match = document.cookie.match(/csrf_token=([^;]+)/) if (match) return decodeURIComponent(match[1]) } catch (e) { console.warn('[timingsBinder] failed to read csrf token', e) } return null } const csrf = readCsrfToken() || '' const map = {} if (Array.isArray(timings)) for (let i = 0; i < timings.length; i++) map[i] = timings[i] else for (const k of Object.keys(timings)) { const key = Number(k) if (!Number.isNaN(key)) map[key] = timings[key] } const params = new URLSearchParams() let maxTime = 0 for (const idxStr of Object.keys(map)) { const idx = Number(idxStr) const val = String(map[idx]) params.append(`timings[${idx}]`, val) const num = Number(val) if (!Number.isNaN(num) && num > maxTime) maxTime = num } params.append('topic_time', String(maxTime)) params.append('topic_id', String(topicId)) const url = 'https://linux.do/topics/timings' const headers = { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', 'x-requested-with': 'XMLHttpRequest' } if (csrf) headers['x-csrf-token'] = csrf return await fetch(url, { method: 'POST', body: params.toString(), credentials: 'same-origin', headers }) } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } async function fetchPostsForTopic(topicId) { const url = `/t/${topicId}/posts.json` const resp = await fetch(url, { credentials: 'same-origin' }) if (!resp.ok) throw new Error(`failed to fetch posts.json: ${resp.status}`) const data = await resp.json() let posts = [] let totalCount = 0 if (data && data.post_stream && Array.isArray(data.post_stream.posts)) { posts = data.post_stream.posts if (posts.length > 0 && typeof posts[0].posts_count === 'number') totalCount = posts[0].posts_count } if ((!posts || posts.length === 0) && data && Array.isArray(data.posts)) posts = data.posts if (!totalCount) { if (data && typeof data.highest_post_number === 'number') totalCount = data.highest_post_number else if (data && typeof data.posts_count === 'number') totalCount = data.posts_count else if (posts && posts.length > 0) totalCount = posts.length } return { posts, totalCount } } async function autoReadAll(topicId) { try { let tid = topicId || 0 if (!tid) { const m1 = window.location.pathname.match(/t\/topic\/(\d+)/) const m2 = window.location.pathname.match(/t\/(\d+)/) if (m1 && m1[1]) tid = Number(m1[1]) else if (m2 && m2[1]) tid = Number(m2[1]) else { const el = document.querySelector('[data-topic-id]') if (el) tid = Number(el.getAttribute('data-topic-id')) || 0 } } if (!tid) { notify('无法推断 topic_id,自动阅读取消', 'error') return } notify(`开始自动阅读话题 ${tid} 的所有帖子...`, 'info') const { posts, totalCount } = await fetchPostsForTopic(tid) if ((!posts || posts.length === 0) && !totalCount) { notify('未获取到任何帖子或总数信息', 'error') return } const total = totalCount || posts.length const postNumbers = [] for (let n = 1; n <= total; n++) postNumbers.push(n) const BATCH_SIZE = 7 for (let i = 0; i < postNumbers.length; i += BATCH_SIZE) { const batch = postNumbers.slice(i, i + BATCH_SIZE) const timings = {} for (const pn of batch) timings[pn] = 1e3 try { await postTimings(tid, timings) notify(`已标记 ${Object.keys(timings).length} 个帖子为已读(发送)`, 'success') } catch (e) { notify('发送阅读标记失败: ' + (e && e.message ? e.message : String(e)), 'error') } const delay = 500 + Math.floor(Math.random() * 1e3) await sleep(delay) } notify('自动阅读完成', 'success') } catch (e) { notify('自动阅读异常: ' + (e && e.message ? e.message : String(e)), 'error') } } window.autoReadAllReplies = autoReadAll function insertIntoEditor(text) { const textArea = document.querySelector('textarea.d-editor-input') const richEle = document.querySelector('.ProseMirror.d-editor-input') if (!textArea && !richEle) { console.error('找不到输入框') return } if (textArea) { const start = textArea.selectionStart const end = textArea.selectionEnd const value = textArea.value textArea.value = value.substring(0, start) + text + value.substring(end) textArea.setSelectionRange(start + text.length, start + text.length) textArea.focus() const event = new Event('input', { bubbles: true }) textArea.dispatchEvent(event) } else if (richEle) { const selection = window.getSelection() if (selection && selection.rangeCount > 0) { const range = selection.getRangeAt(0) const textNode = document.createTextNode(text) range.insertNode(textNode) range.setStartAfter(textNode) range.setEndAfter(textNode) selection.removeAllRanges() selection.addRange(range) } richEle.focus() } } const ImageUploader = class { waitingQueue = [] uploadingQueue = [] failedQueue = [] successQueue = [] isProcessing = false maxRetries = 2 progressDialog = null async uploadImage(file) { return new Promise((resolve, reject) => { const item = { id: `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, file, resolve, reject, retryCount: 0, status: 'waiting', timestamp: Date.now() } this.waitingQueue.push(item) this.updateProgressDialog() this.processQueue() }) } moveToQueue(item, targetStatus) { this.waitingQueue = this.waitingQueue.filter(i => i.id !== item.id) this.uploadingQueue = this.uploadingQueue.filter(i => i.id !== item.id) this.failedQueue = this.failedQueue.filter(i => i.id !== item.id) this.successQueue = this.successQueue.filter(i => i.id !== item.id) item.status = targetStatus switch (targetStatus) { case 'waiting': this.waitingQueue.push(item) break case 'uploading': this.uploadingQueue.push(item) break case 'failed': this.failedQueue.push(item) break case 'success': this.successQueue.push(item) break } this.updateProgressDialog() } async processQueue() { if (this.isProcessing || this.waitingQueue.length === 0) return this.isProcessing = true while (this.waitingQueue.length > 0) { const item = this.waitingQueue.shift() if (!item) continue this.moveToQueue(item, 'uploading') try { const result = await this.performUpload(item.file) item.result = result this.moveToQueue(item, 'success') item.resolve(result) const markdown = `` insertIntoEditor(markdown) } catch (error) { item.error = error if (this.shouldRetry(error, item)) { item.retryCount++ if (error.error_type === 'rate_limit' && error.extras?.wait_seconds) await this.sleep(error.extras.wait_seconds * 1e3) else await this.sleep(Math.pow(2, item.retryCount) * 1e3) this.moveToQueue(item, 'waiting') } else { this.moveToQueue(item, 'failed') item.reject(error) } } } this.isProcessing = false } shouldRetry(error, item) { if (item.retryCount >= this.maxRetries) return false return error.error_type === 'rate_limit' } retryFailedItem(itemId) { const item = this.failedQueue.find(i => i.id === itemId) if (item && item.retryCount < this.maxRetries) { item.retryCount++ this.moveToQueue(item, 'waiting') this.processQueue() } } showProgressDialog() { if (this.progressDialog) return this.progressDialog = this.createProgressDialog() document.body.appendChild(this.progressDialog) } hideProgressDialog() { if (this.progressDialog) { this.progressDialog.remove() this.progressDialog = null } } updateProgressDialog() { if (!this.progressDialog) return const allItems = [ ...this.waitingQueue, ...this.uploadingQueue, ...this.failedQueue, ...this.successQueue ] this.renderQueueItems(this.progressDialog, allItems) } async sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } createProgressDialog() { const dialog = document.createElement('div') dialog.style.cssText = ` position: fixed; top: 20px; right: 20px; width: 350px; max-height: 400px; background: white; border-radius: 8px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15); z-index: 10000; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; border: 1px solid #e5e7eb; overflow: hidden; ` const header = document.createElement('div') header.style.cssText = ` padding: 16px 20px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; font-weight: 600; font-size: 14px; color: #374151; display: flex; justify-content: space-between; align-items: center; ` header.textContent = '图片上传队列' const closeButton = document.createElement('button') closeButton.innerHTML = '✕' closeButton.style.cssText = ` background: none; border: none; font-size: 16px; cursor: pointer; color: #6b7280; padding: 4px; border-radius: 4px; transition: background-color 0.2s; ` closeButton.addEventListener('click', () => { this.hideProgressDialog() }) closeButton.addEventListener('mouseenter', () => { closeButton.style.backgroundColor = '#e5e7eb' }) closeButton.addEventListener('mouseleave', () => { closeButton.style.backgroundColor = 'transparent' }) header.appendChild(closeButton) const content = document.createElement('div') content.className = 'upload-queue-content' content.style.cssText = ` max-height: 320px; overflow-y: auto; padding: 12px; ` dialog.appendChild(header) dialog.appendChild(content) return dialog } renderQueueItems(dialog, allItems) { const content = dialog.querySelector('.upload-queue-content') if (!content) return content.innerHTML = '' if (allItems.length === 0) { const emptyState = document.createElement('div') emptyState.style.cssText = ` text-align: center; color: #6b7280; font-size: 14px; padding: 20px; ` emptyState.textContent = '暂无上传任务' content.appendChild(emptyState) return } allItems.forEach(item => { const itemEl = document.createElement('div') itemEl.style.cssText = ` display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; margin-bottom: 8px; background: #f9fafb; border-radius: 6px; border-left: 4px solid ${this.getStatusColor(item.status)}; ` const leftSide = document.createElement('div') leftSide.style.cssText = ` flex: 1; min-width: 0; ` const fileName = document.createElement('div') fileName.style.cssText = ` font-size: 13px; font-weight: 500; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; ` fileName.textContent = item.file.name const status = document.createElement('div') status.style.cssText = ` font-size: 12px; color: #6b7280; margin-top: 2px; ` status.textContent = this.getStatusText(item) leftSide.appendChild(fileName) leftSide.appendChild(status) const rightSide = document.createElement('div') rightSide.style.cssText = ` display: flex; align-items: center; gap: 8px; ` if (item.status === 'failed' && item.retryCount < this.maxRetries) { const retryButton = document.createElement('button') retryButton.innerHTML = '🔄' retryButton.style.cssText = ` background: none; border: none; cursor: pointer; font-size: 14px; padding: 4px; border-radius: 4px; transition: background-color 0.2s; ` retryButton.title = '重试上传' retryButton.addEventListener('click', () => { this.retryFailedItem(item.id) }) retryButton.addEventListener('mouseenter', () => { retryButton.style.backgroundColor = '#e5e7eb' }) retryButton.addEventListener('mouseleave', () => { retryButton.style.backgroundColor = 'transparent' }) rightSide.appendChild(retryButton) } const statusIcon = document.createElement('div') statusIcon.style.cssText = ` font-size: 16px; ` statusIcon.textContent = this.getStatusIcon(item.status) rightSide.appendChild(statusIcon) itemEl.appendChild(leftSide) itemEl.appendChild(rightSide) content.appendChild(itemEl) }) } getStatusColor(status) { switch (status) { case 'waiting': return '#f59e0b' case 'uploading': return '#3b82f6' case 'success': return '#10b981' case 'failed': return '#ef4444' default: return '#6b7280' } } getStatusText(item) { switch (item.status) { case 'waiting': return '等待上传' case 'uploading': return '正在上传...' case 'success': return '上传成功' case 'failed': if (item.error?.error_type === 'rate_limit') return `上传失败 - 请求过于频繁 (重试 ${item.retryCount}/${this.maxRetries})` return `上传失败 (重试 ${item.retryCount}/${this.maxRetries})` default: return '未知状态' } } getStatusIcon(status) { switch (status) { case 'waiting': return '⏳' case 'uploading': return '📤' case 'success': return '✅' case 'failed': return '❌' default: return '❓' } } async performUpload(file) { const sha1 = await this.calculateSHA1(file) const formData = new FormData() formData.append('upload_type', 'composer') formData.append('relativePath', 'null') formData.append('name', file.name) formData.append('type', file.type) formData.append('sha1_checksum', sha1) formData.append('file', file, file.name) const csrfToken = this.getCSRFToken() const headers = { 'X-Csrf-Token': csrfToken } if (document.cookie) headers['Cookie'] = document.cookie const response = await fetch( `https://linux.do/uploads.json?client_id=f06cb5577ba9410d94b9faf94e48c2d8`, { method: 'POST', headers, body: formData } ) if (!response.ok) throw await response.json() return await response.json() } getCSRFToken() { const metaToken = document.querySelector('meta[name="csrf-token"]') if (metaToken) return metaToken.content const match = document.cookie.match(/csrf_token=([^;]+)/) if (match) return decodeURIComponent(match[1]) const hiddenInput = document.querySelector('input[name="authenticity_token"]') if (hiddenInput) return hiddenInput.value console.warn('[Image Uploader] No CSRF token found') return '' } async calculateSHA1(file) { const text = `${file.name}-${file.size}-${file.lastModified}` const data = new TextEncoder().encode(text) if (crypto.subtle) try { const hashBuffer = await crypto.subtle.digest('SHA-1', data) return Array.from(new Uint8Array(hashBuffer)) .map(b => b.toString(16).padStart(2, '0')) .join('') } catch (e) { console.warn('[Image Uploader] Could not calculate SHA1, using fallback') } let hash = 0 for (let i = 0; i < text.length; i++) { const char = text.charCodeAt(i) hash = (hash << 5) - hash + char hash = hash & hash } return Math.abs(hash).toString(16).padStart(40, '0') } } const uploader = new ImageUploader() function extractEmojiFromImage(img, titleElement) { const url = img.src if (!url || !url.startsWith('http')) return null let name = '' const parts = (titleElement.textContent || '').split('·') if (parts.length > 0) name = parts[0].trim() if (!name || name.length < 2) name = img.alt || img.title || extractNameFromUrl(url) name = name.trim() if (name.length === 0) name = '表情' return { name, url } } function extractNameFromUrl(url) { try { const nameWithoutExt = (new URL(url).pathname.split('/').pop() || '').replace( /\.[^/.]+$/, '' ) const decoded = decodeURIComponent(nameWithoutExt) if (/^[0-9a-f]{8,}$/i.test(decoded) || decoded.length < 2) return '表情' return decoded || '表情' } catch { return '表情' } } function createAddButton(emojiData) { const link = createEl('a', { className: 'image-source-link emoji-add-link', style: ` color: #ffffff; text-decoration: none; cursor: pointer; display: inline-flex; align-items: center; font-size: inherit; font-family: inherit; background: linear-gradient(135deg, #4f46e5, #7c3aed); border: 2px solid #ffffff; border-radius: 6px; padding: 4px 8px; margin: 0 2px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); transition: all 0.2s ease; font-weight: 600; ` }) link.addEventListener('mouseenter', () => { if (!link.innerHTML.includes('已添加') && !link.innerHTML.includes('失败')) { link.style.background = 'linear-gradient(135deg, #3730a3, #5b21b6)' link.style.transform = 'scale(1.05)' link.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)' } }) link.addEventListener('mouseleave', () => { if (!link.innerHTML.includes('已添加') && !link.innerHTML.includes('失败')) { link.style.background = 'linear-gradient(135deg, #4f46e5, #7c3aed)' link.style.transform = 'scale(1)' link.style.boxShadow = '0 2px 4px rgba(0, 0, 0, 0.2)' } }) link.innerHTML = ` <svg class="fa d-icon d-icon-plus svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="width: 1em; height: 1em; fill: currentColor; margin-right: 4px;"> <path d="M12 4c.55 0 1 .45 1 1v6h6c.55 0 1 .45 1 1s-.45 1-1 1h-6v6c0 .55-.45 1-1 1s-1-.45-1-1v-6H5c-.55 0-1-.45-1-1s.45-1 1-1h6V5c0-.55.45-1 1-1z"/> </svg>添加表情 ` link.title = '添加到用户表情' link.addEventListener('click', async e => { e.preventDefault() e.stopPropagation() const originalHTML = link.innerHTML const originalStyle = link.style.cssText try { addEmojiToUserscript(emojiData) try { uploader.showProgressDialog() } catch (e$1) { console.warn('[Userscript] uploader.showProgressDialog failed:', e$1) } link.innerHTML = ` <svg class="fa d-icon d-icon-check svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="width: 1em; height: 1em; fill: currentColor; margin-right: 4px;"> <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/> </svg>已添加 ` link.style.background = 'linear-gradient(135deg, #10b981, #059669)' link.style.color = '#ffffff' link.style.border = '2px solid #ffffff' link.style.boxShadow = '0 2px 4px rgba(16, 185, 129, 0.3)' setTimeout(() => { link.innerHTML = originalHTML link.style.cssText = originalStyle }, 2e3) } catch (error) { console.error('[Emoji Extension Userscript] Failed to add emoji:', error) link.innerHTML = ` <svg class="fa d-icon d-icon-times svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style="width: 1em; height: 1em; fill: currentColor; margin-right: 4px;"> <path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/> </svg>失败 ` link.style.background = 'linear-gradient(135deg, #ef4444, #dc2626)' link.style.color = '#ffffff' link.style.border = '2px solid #ffffff' link.style.boxShadow = '0 2px 4px rgba(239, 68, 68, 0.3)' setTimeout(() => { link.innerHTML = originalHTML link.style.cssText = originalStyle }, 2e3) } }) return link } function processLightbox(lightbox) { if (lightbox.querySelector('.emoji-add-link')) return const img = lightbox.querySelector('.mfp-img') const title = lightbox.querySelector('.mfp-title') if (!img || !title) return const emojiData = extractEmojiFromImage(img, title) if (!emojiData) return const addButton = createAddButton(emojiData) const sourceLink = title.querySelector('a.image-source-link') if (sourceLink) { const separator = document.createTextNode(' · ') title.insertBefore(separator, sourceLink) title.insertBefore(addButton, sourceLink) } else { title.appendChild(document.createTextNode(' · ')) title.appendChild(addButton) } } function processAllLightboxes() { document.querySelectorAll('.mfp-wrap.mfp-gallery').forEach(lightbox => { if ( lightbox.classList.contains('mfp-wrap') && lightbox.classList.contains('mfp-gallery') && lightbox.querySelector('.mfp-img') ) processLightbox(lightbox) }) } function initOneClickAdd() { console.log('[Emoji Extension Userscript] Initializing one-click add functionality') setTimeout(processAllLightboxes, 500) new MutationObserver(mutations => { let hasNewLightbox = false mutations.forEach(mutation => { if (mutation.type === 'childList') mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { const element = node if (element.classList && element.classList.contains('mfp-wrap')) hasNewLightbox = true } }) }) if (hasNewLightbox) setTimeout(processAllLightboxes, 100) }).observe(document.body, { childList: true, subtree: true }) document.addEventListener('visibilitychange', () => { if (!document.hidden) setTimeout(processAllLightboxes, 200) }) } function getBuildPlatform() { try { return 'original' } catch { return 'original' } } function detectRuntimePlatform() { try { const isMobileSize = window.innerWidth <= 768 const userAgent = navigator.userAgent.toLowerCase() const isMobileUserAgent = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test(userAgent) const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0 if (isMobileSize && (isMobileUserAgent || isTouchDevice)) return 'mobile' else if (!isMobileSize && !isMobileUserAgent) return 'pc' return 'original' } catch { return 'original' } } function getEffectivePlatform() { const buildPlatform = getBuildPlatform() if (buildPlatform === 'original') return detectRuntimePlatform() return buildPlatform } function getPlatformUIConfig() { switch (getEffectivePlatform()) { case 'mobile': return { emojiPickerMaxHeight: '60vh', emojiPickerColumns: 4, emojiSize: 32, isModal: true, useCompactLayout: true, showSearchBar: true, floatingButtonSize: 48 } case 'pc': return { emojiPickerMaxHeight: '400px', emojiPickerColumns: 6, emojiSize: 24, isModal: false, useCompactLayout: false, showSearchBar: true, floatingButtonSize: 40 } default: return { emojiPickerMaxHeight: '350px', emojiPickerColumns: 5, emojiSize: 28, isModal: false, useCompactLayout: false, showSearchBar: true, floatingButtonSize: 44 } } } function getPlatformToolbarSelectors() { const platform = getEffectivePlatform() const baseSelectors = [ '.d-editor-button-bar[role="toolbar"]', '.chat-composer__inner-container' ] switch (platform) { case 'mobile': return [ ...baseSelectors, '.mobile-composer-toolbar', '.chat-composer-mobile', '[data-mobile-toolbar]', '.discourse-mobile .d-editor-button-bar' ] case 'pc': return [ ...baseSelectors, '.desktop-composer-toolbar', '.chat-composer-desktop', '[data-desktop-toolbar]', '.discourse-desktop .d-editor-button-bar' ] default: return baseSelectors } } function logPlatformInfo() { const buildPlatform = getBuildPlatform() const runtimePlatform = detectRuntimePlatform() const effectivePlatform = getEffectivePlatform() const config = getPlatformUIConfig() console.log('[Platform] Build target:', buildPlatform) console.log('[Platform] Runtime detected:', runtimePlatform) console.log('[Platform] Effective platform:', effectivePlatform) console.log('[Platform] UI config:', config) console.log('[Platform] Screen size:', `${window.innerWidth}x${window.innerHeight}`) console.log( '[Platform] User agent mobile:', /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i.test( navigator.userAgent.toLowerCase() ) ) console.log( '[Platform] Touch device:', 'ontouchstart' in window || navigator.maxTouchPoints > 0 ) } function injectGlobalThemeStyles() { if (themeStylesInjected || typeof document === 'undefined') return themeStylesInjected = true const style = document.createElement('style') style.id = 'emoji-extension-theme-globals' style.textContent = ` /* Global CSS variables for emoji extension theme support */ :root { /* Light theme (default) */ --emoji-modal-bg: #ffffff; --emoji-modal-text: #333333; --emoji-modal-border: #dddddd; --emoji-modal-input-bg: #ffffff; --emoji-modal-label: #555555; --emoji-modal-button-bg: #f5f5f5; --emoji-modal-primary-bg: #1890ff; --emoji-preview-bg: #ffffff; --emoji-preview-text: #222222; --emoji-preview-border: rgba(0,0,0,0.08); --emoji-button-gradient-start: #667eea; --emoji-button-gradient-end: #764ba2; --emoji-button-shadow: rgba(0, 0, 0, 0.15); --emoji-button-hover-shadow: rgba(0, 0, 0, 0.2); } /* Dark theme */ @media (prefers-color-scheme: dark) { :root { --emoji-modal-bg: #2d2d2d; --emoji-modal-text: #e6e6e6; --emoji-modal-border: #444444; --emoji-modal-input-bg: #3a3a3a; --emoji-modal-label: #cccccc; --emoji-modal-button-bg: #444444; --emoji-modal-primary-bg: #1677ff; --emoji-preview-bg: rgba(32,33,36,0.94); --emoji-preview-text: #e6e6e6; --emoji-preview-border: rgba(255,255,255,0.12); --emoji-button-gradient-start: #4a5568; --emoji-button-gradient-end: #2d3748; --emoji-button-shadow: rgba(0, 0, 0, 0.3); --emoji-button-hover-shadow: rgba(0, 0, 0, 0.4); } } ` document.head.appendChild(style) } let themeStylesInjected const init_themeSupport = __esmMin(() => { themeStylesInjected = false }) init_themeSupport() function injectEmojiPickerStyles() { if (typeof document === 'undefined') return if (document.getElementById('emoji-picker-styles')) return injectGlobalThemeStyles() const css = ` .emoji-picker-hover-preview{ position:fixed; pointer-events:none; display:none; z-index:1000002; max-width:320px; max-height:320px; overflow:hidden; border-radius:8px; box-shadow:0 6px 20px rgba(0,0,0,0.32); background:var(--emoji-preview-bg); padding:8px; transition:opacity .3s ease, transform .12s ease; border: 1px solid var(--emoji-preview-border); backdrop-filter: blur(10px); } .emoji-picker-hover-preview img.emoji-picker-hover-img{ display:block; max-width:100%; max-height:220px; object-fit:contain; } .emoji-picker-hover-preview .emoji-picker-hover-label{ font-size:12px; color:var(--emoji-preview-text); margin-top:8px; text-align:center; word-break:break-word; font-weight: 500; } ` const style = document.createElement('style') style.id = 'emoji-picker-styles' style.textContent = css document.head.appendChild(style) } function isImageUrl(value) { if (!value) return false let v = value.trim() if (/^url\(/i.test(v)) { const inner = v .replace(/^url\(/i, '') .replace(/\)$/, '') .trim() if ( (inner.startsWith('"') && inner.endsWith('"')) || (inner.startsWith("'") && inner.endsWith("'")) ) v = inner.slice(1, -1).trim() else v = inner } if (v.startsWith('data:image/')) return true if (v.startsWith('blob:')) return true if (v.startsWith('//')) v = 'https:' + v if (/\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)(\?.*)?$/i.test(v)) return true try { const url = new URL(v) const protocol = url.protocol if (protocol === 'http:' || protocol === 'https:' || protocol.endsWith(':')) { if (/\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)(\?.*)?$/i.test(url.pathname)) return true if (/format=|ext=|type=image|image_type=/i.test(url.search)) return true } } catch {} return false } const __vitePreload = function preload(baseModule, deps, importerUrl) { const promise = Promise.resolve() function handlePreloadError(err$2) { const e$1 = new Event('vite:preloadError', { cancelable: true }) e$1.payload = err$2 window.dispatchEvent(e$1) if (!e$1.defaultPrevented) throw err$2 } return promise.then(res => { for (const item of res || []) { if (item.status !== 'rejected') continue handlePreloadError(item.reason) } return baseModule().catch(handlePreloadError) }) } function injectManagerStyles() { if (__managerStylesInjected) return __managerStylesInjected = true document.head.appendChild( createEl('style', { attrs: { 'data-emoji-manager-styles': '1' }, text: ` /* Modal backdrop */ .emoji-manager-wrapper { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.8); z-index: 999999; display: flex; align-items: center; justify-content: center; } /* Main modal panel */ .emoji-manager-panel { background: white; border-radius: 8px; max-width: 90vw; max-height: 90vh; width: 1000px; height: 600px; display: grid; grid-template-columns: 300px 1fr; grid-template-rows: 1fr auto; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,0.3); } /* Left panel - groups list */ .emoji-manager-left { background: #f8f9fa; border-right: 1px solid #e9ecef; display: flex; flex-direction: column; overflow: hidden; } .emoji-manager-left-header { display: flex; align-items: center; padding: 16px; border-bottom: 1px solid #e9ecef; background: white; } .emoji-manager-addgroup-row { display: flex; gap: 8px; padding: 12px; border-bottom: 1px solid #e9ecef; } .emoji-manager-groups-list { flex: 1; overflow-y: auto; padding: 8px; } .emoji-manager-groups-list > div { padding: 12px; border-radius: 6px; cursor: pointer; margin-bottom: 4px; transition: background-color 0.2s; } .emoji-manager-groups-list > div:hover { background: #e9ecef; } .emoji-manager-groups-list > div:focus { outline: none; box-shadow: inset 0 0 0 2px #007bff; } /* Right panel - emoji display and editing */ .emoji-manager-right { background: white; display: flex; flex-direction: column; overflow: hidden; } .emoji-manager-right-header { display: flex; align-items: center; justify-content: space-between; padding: 16px; border-bottom: 1px solid #e9ecef; } .emoji-manager-right-main { flex: 1; overflow-y: auto; padding: 16px; } .emoji-manager-emojis { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 12px; margin-bottom: 16px; } .emoji-manager-card { display: flex; flex-direction: column; gap: 8px; align-items: center; padding: 12px; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 8px; transition: transform 0.2s, box-shadow 0.2s; } .emoji-manager-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); } .emoji-manager-card-img { width: 80px; height: 80px; /* Prevent extremely large images from breaking the layout by limiting their rendered size relative to the card. Use both absolute and percentage-based constraints so user-provided pixel sizes (from edit form) still work but will not overflow the card or modal. */ max-width: 90%; max-height: 60vh; /* allow tall images but cap at viewport height */ object-fit: contain; border-radius: 6px; background: white; } .emoji-manager-card-name { font-size: 12px; color: #495057; text-align: center; width: 100%; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 500; } .emoji-manager-card-actions { display: flex; gap: 6px; } /* Add emoji form */ .emoji-manager-add-emoji-form { padding: 16px; background: #f8f9fa; border-top: 1px solid #e9ecef; display: flex; gap: 8px; align-items: center; } /* Footer */ .emoji-manager-footer { grid-column: 1 / -1; display: flex; gap: 8px; justify-content: space-between; padding: 16px; background: #f8f9fa; border-top: 1px solid #e9ecef; } /* Editor panel - popup modal */ .emoji-manager-editor-panel { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; border: 1px solid #e9ecef; border-radius: 8px; padding: 24px; box-shadow: 0 10px 40px rgba(0,0,0,0.3); z-index: 1000000; min-width: 400px; } .emoji-manager-editor-preview { width: 100px; height: 100px; /* editor preview should be bounded to avoid huge remote images while still allowing percentage-based scaling */ max-width: 100%; max-height: 40vh; object-fit: contain; border-radius: 8px; background: #f8f9fa; margin: 0 auto 16px; display: block; } /* Hover preview (moved from inline styles) */ .emoji-manager-hover-preview { position: fixed; pointer-events: none; z-index: 1000002; display: none; /* For hover previews allow a generous but bounded size relative to viewport to avoid covering entire UI or pushing content off-screen. */ max-width: 30vw; max-height: 40vh; width: auto; height: auto; border: 1px solid rgba(0,0,0,0.1); background: #fff; padding: 4px; border-radius: 6px; box-shadow: 0 6px 18px rgba(0,0,0,0.12); } /* Form styling */ .form-control { width: 100%; padding: 8px 12px; border: 1px solid #ced4da; border-radius: 4px; font-size: 14px; margin-bottom: 8px; } .btn { padding: 8px 16px; border: 1px solid transparent; border-radius: 4px; font-size: 14px; cursor: pointer; transition: all 0.2s; } .btn-primary { background-color: #007bff; color: white; } .btn-primary:hover { background-color: #0056b3; } .btn-sm { padding: 4px 8px; font-size: 12px; } ` }) ) } let __managerStylesInjected const init_styles = __esmMin(() => { init_createEl() __managerStylesInjected = false }) const manager_exports = /* @__PURE__ */ __export({ openManagementInterface: () => openManagementInterface }) function createEditorPopup(groupId, index, renderGroups, renderSelectedGroup) { const group = userscriptState.emojiGroups.find(g => g.id === groupId) if (!group) return const emo = group.emojis[index] if (!emo) return const backdrop = createEl('div', { style: ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000000; display: flex; align-items: center; justify-content: center; ` }) const editorPanel = createEl('div', { className: 'emoji-manager-editor-panel' }) const editorTitle = createEl('h3', { text: '编辑表情', className: 'emoji-manager-editor-title', style: 'margin: 0 0 16px 0; text-align: center;' }) const editorPreview = createEl('img', { className: 'emoji-manager-editor-preview' }) editorPreview.src = emo.url const editorWidthInput = createEl('input', { className: 'form-control', placeholder: '宽度 (px) 可选', value: emo.width ? String(emo.width) : '' }) const editorHeightInput = createEl('input', { className: 'form-control', placeholder: '高度 (px) 可选', value: emo.height ? String(emo.height) : '' }) const editorNameInput = createEl('input', { className: 'form-control', placeholder: '名称 (alias)', value: emo.name || '' }) const editorUrlInput = createEl('input', { className: 'form-control', placeholder: '表情图片 URL', value: emo.url || '' }) const buttonContainer = createEl('div', { style: 'display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px;' }) const editorSaveBtn = createEl('button', { text: '保存修改', className: 'btn btn-primary' }) const editorCancelBtn = createEl('button', { text: '取消', className: 'btn' }) buttonContainer.appendChild(editorCancelBtn) buttonContainer.appendChild(editorSaveBtn) editorPanel.appendChild(editorTitle) editorPanel.appendChild(editorPreview) editorPanel.appendChild(editorWidthInput) editorPanel.appendChild(editorHeightInput) editorPanel.appendChild(editorNameInput) editorPanel.appendChild(editorUrlInput) editorPanel.appendChild(buttonContainer) backdrop.appendChild(editorPanel) document.body.appendChild(backdrop) editorUrlInput.addEventListener('input', () => { editorPreview.src = editorUrlInput.value }) editorSaveBtn.addEventListener('click', () => { const newName = (editorNameInput.value || '').trim() const newUrl = (editorUrlInput.value || '').trim() const newWidth = parseInt((editorWidthInput.value || '').trim(), 10) const newHeight = parseInt((editorHeightInput.value || '').trim(), 10) if (!newName || !newUrl) { alert('名称和 URL 均不能为空') return } emo.name = newName emo.url = newUrl if (!isNaN(newWidth) && newWidth > 0) emo.width = newWidth else delete emo.width if (!isNaN(newHeight) && newHeight > 0) emo.height = newHeight else delete emo.height renderGroups() renderSelectedGroup() backdrop.remove() }) editorCancelBtn.addEventListener('click', () => { backdrop.remove() }) backdrop.addEventListener('click', e => { if (e.target === backdrop) backdrop.remove() }) } function openManagementInterface() { injectManagerStyles() const modal = createEl('div', { className: 'emoji-manager-wrapper', attrs: { role: 'dialog', 'aria-modal': 'true' } }) const panel = createEl('div', { className: 'emoji-manager-panel' }) const left = createEl('div', { className: 'emoji-manager-left' }) const leftHeader = createEl('div', { className: 'emoji-manager-left-header' }) const title = createEl('h3', { text: '表情管理器' }) const closeBtn = createEl('button', { text: '×', className: 'btn', style: 'font-size:20px; background:none; border:none; cursor:pointer;' }) leftHeader.appendChild(title) leftHeader.appendChild(closeBtn) left.appendChild(leftHeader) const addGroupRow = createEl('div', { className: 'emoji-manager-addgroup-row' }) const addGroupInput = createEl('input', { placeholder: '新分组 id', className: 'form-control' }) const addGroupBtn = createEl('button', { text: '添加', className: 'btn' }) addGroupRow.appendChild(addGroupInput) addGroupRow.appendChild(addGroupBtn) left.appendChild(addGroupRow) const groupsList = createEl('div', { className: 'emoji-manager-groups-list' }) left.appendChild(groupsList) const right = createEl('div', { className: 'emoji-manager-right' }) const rightHeader = createEl('div', { className: 'emoji-manager-right-header' }) const groupTitle = createEl('h4') groupTitle.textContent = '' const deleteGroupBtn = createEl('button', { text: '删除分组', className: 'btn', style: 'background:#ef4444; color:#fff;' }) rightHeader.appendChild(groupTitle) rightHeader.appendChild(deleteGroupBtn) right.appendChild(rightHeader) const managerRightMain = createEl('div', { className: 'emoji-manager-right-main' }) const emojisContainer = createEl('div', { className: 'emoji-manager-emojis' }) managerRightMain.appendChild(emojisContainer) const addEmojiForm = createEl('div', { className: 'emoji-manager-add-emoji-form' }) const emojiUrlInput = createEl('input', { placeholder: '表情图片 URL', className: 'form-control' }) const emojiNameInput = createEl('input', { placeholder: '名称 (alias)', className: 'form-control' }) const emojiWidthInput = createEl('input', { placeholder: '宽度 (px) 可选', className: 'form-control' }) const emojiHeightInput = createEl('input', { placeholder: '高度 (px) 可选', className: 'form-control' }) const addEmojiBtn = createEl('button', { text: '添加表情', className: 'btn btn-primary' }) addEmojiForm.appendChild(emojiUrlInput) addEmojiForm.appendChild(emojiNameInput) addEmojiForm.appendChild(emojiWidthInput) addEmojiForm.appendChild(emojiHeightInput) addEmojiForm.appendChild(addEmojiBtn) managerRightMain.appendChild(addEmojiForm) right.appendChild(managerRightMain) const footer = createEl('div', { className: 'emoji-manager-footer' }) const exportBtn = createEl('button', { text: '导出', className: 'btn' }) const importBtn = createEl('button', { text: '导入', className: 'btn' }) const exitBtn = createEl('button', { text: '退出', className: 'btn' }) exitBtn.addEventListener('click', () => modal.remove()) const saveBtn = createEl('button', { text: '保存', className: 'btn btn-primary' }) const syncBtn = createEl('button', { text: '同步管理器', className: 'btn' }) footer.appendChild(syncBtn) footer.appendChild(exportBtn) footer.appendChild(importBtn) footer.appendChild(exitBtn) footer.appendChild(saveBtn) panel.appendChild(left) panel.appendChild(right) panel.appendChild(footer) modal.appendChild(panel) document.body.appendChild(modal) let selectedGroupId = null function renderGroups() { groupsList.innerHTML = '' if (!selectedGroupId && userscriptState.emojiGroups.length > 0) selectedGroupId = userscriptState.emojiGroups[0].id userscriptState.emojiGroups.forEach(g => { const row = createEl('div', { style: 'display:flex; justify-content:space-between; align-items:center; padding:6px; border-radius:4px; cursor:pointer;', text: `${g.name || g.id} (${(g.emojis || []).length})`, attrs: { tabindex: '0', 'data-group-id': g.id } }) const selectGroup = () => { selectedGroupId = g.id renderGroups() renderSelectedGroup() } row.addEventListener('click', selectGroup) row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() selectGroup() } }) if (selectedGroupId === g.id) row.style.background = '#f0f8ff' groupsList.appendChild(row) }) } function showEditorFor(groupId, index) { createEditorPopup(groupId, index, renderGroups, renderSelectedGroup) } function renderSelectedGroup() { const group = userscriptState.emojiGroups.find(g => g.id === selectedGroupId) || null groupTitle.textContent = group ? group.name || group.id : '' emojisContainer.innerHTML = '' if (!group) return ;(Array.isArray(group.emojis) ? group.emojis : []).forEach((emo, idx) => { const card = createEl('div', { className: 'emoji-manager-card' }) const img = createEl('img', { src: emo.url, alt: emo.name, className: 'emoji-manager-card-img' }) if (emo.width) img.style.width = typeof emo.width === 'number' ? emo.width + 'px' : emo.width if (emo.height) img.style.height = typeof emo.height === 'number' ? emo.height + 'px' : emo.height const name = createEl('div', { text: emo.name, className: 'emoji-manager-card-name' }) const actions = createEl('div', { className: 'emoji-manager-card-actions' }) const edit = createEl('button', { text: '编辑', className: 'btn btn-sm' }) edit.addEventListener('click', () => { showEditorFor(group.id, idx) }) const del = createEl('button', { text: '删除', className: 'btn btn-sm' }) del.addEventListener('click', () => { group.emojis.splice(idx, 1) renderGroups() renderSelectedGroup() }) actions.appendChild(edit) actions.appendChild(del) card.appendChild(img) card.appendChild(name) card.appendChild(actions) emojisContainer.appendChild(card) bindHoverPreview(img, emo) }) } let hoverPreviewEl = null function ensureHoverPreview$1() { if (hoverPreviewEl && document.body.contains(hoverPreviewEl)) return hoverPreviewEl hoverPreviewEl = createEl('img', { className: 'emoji-manager-hover-preview' }) document.body.appendChild(hoverPreviewEl) return hoverPreviewEl } function bindHoverPreview(targetImg, emo) { const preview = ensureHoverPreview$1() function onEnter(e) { preview.src = emo.url if (emo.width) preview.style.width = typeof emo.width === 'number' ? emo.width + 'px' : emo.width else preview.style.width = '' if (emo.height) preview.style.height = typeof emo.height === 'number' ? emo.height + 'px' : emo.height else preview.style.height = '' preview.style.display = 'block' movePreview(e) } function movePreview(e) { const pad = 12 const vw = window.innerWidth const vh = window.innerHeight const rect = preview.getBoundingClientRect() let left$1 = e.clientX + pad let top = e.clientY + pad if (left$1 + rect.width > vw) left$1 = e.clientX - rect.width - pad if (top + rect.height > vh) top = e.clientY - rect.height - pad preview.style.left = left$1 + 'px' preview.style.top = top + 'px' } function onLeave() { if (preview) preview.style.display = 'none' } targetImg.addEventListener('mouseenter', onEnter) targetImg.addEventListener('mousemove', movePreview) targetImg.addEventListener('mouseleave', onLeave) } addGroupBtn.addEventListener('click', () => { const id = (addGroupInput.value || '').trim() if (!id) return alert('请输入分组 id') if (userscriptState.emojiGroups.find(g => g.id === id)) return alert('分组已存在') userscriptState.emojiGroups.push({ id, name: id, emojis: [] }) addGroupInput.value = '' const newIdx = userscriptState.emojiGroups.findIndex(g => g.id === id) if (newIdx >= 0) selectedGroupId = userscriptState.emojiGroups[newIdx].id renderGroups() renderSelectedGroup() }) addEmojiBtn.addEventListener('click', () => { if (!selectedGroupId) return alert('请先选择分组') const url = (emojiUrlInput.value || '').trim() const name = (emojiNameInput.value || '').trim() const widthVal = (emojiWidthInput.value || '').trim() const heightVal = (emojiHeightInput.value || '').trim() const width = widthVal ? parseInt(widthVal, 10) : NaN const height = heightVal ? parseInt(heightVal, 10) : NaN if (!url || !name) return alert('请输入 url 和 名称') const group = userscriptState.emojiGroups.find(g => g.id === selectedGroupId) if (!group) return group.emojis = group.emojis || [] const newEmo = { url, name } if (!isNaN(width) && width > 0) newEmo.width = width if (!isNaN(height) && height > 0) newEmo.height = height group.emojis.push(newEmo) emojiUrlInput.value = '' emojiNameInput.value = '' emojiWidthInput.value = '' emojiHeightInput.value = '' renderGroups() renderSelectedGroup() }) deleteGroupBtn.addEventListener('click', () => { if (!selectedGroupId) return alert('请先选择分组') const idx = userscriptState.emojiGroups.findIndex(g => g.id === selectedGroupId) if (idx >= 0) { if (!confirm('确认删除该分组?该操作不可撤销')) return userscriptState.emojiGroups.splice(idx, 1) if (userscriptState.emojiGroups.length > 0) selectedGroupId = userscriptState.emojiGroups[Math.min(idx, userscriptState.emojiGroups.length - 1)].id else selectedGroupId = null renderGroups() renderSelectedGroup() } }) exportBtn.addEventListener('click', () => { const data = exportUserscriptData() navigator.clipboard .writeText(data) .then(() => alert('已复制到剪贴板')) .catch(() => { const ta = createEl('textarea', { value: data }) document.body.appendChild(ta) ta.select() }) }) importBtn.addEventListener('click', () => { const ta = createEl('textarea', { placeholder: '粘贴 JSON 后点击确认', style: 'width:100%;height:200px;margin-top:8px;' }) const ok = createEl('button', { text: '确认导入', style: 'padding:6px 8px;margin-top:6px;' }) const container = createEl('div') container.appendChild(ta) container.appendChild(ok) const importModal = createEl('div', { style: 'position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000001;' }) const box = createEl('div', { style: 'background:#fff;padding:12px;border-radius:6px;width:90%;max-width:700px;' }) box.appendChild(container) importModal.appendChild(box) document.body.appendChild(importModal) ok.addEventListener('click', () => { try { const json = ta.value.trim() if (!json) return if (importUserscriptData(json)) { alert('导入成功,请保存以持久化') loadDataFromLocalStorage$1() renderGroups() renderSelectedGroup() } else alert('导入失败:格式错误') } catch (e) { alert('导入异常:' + e) } importModal.remove() }) }) saveBtn.addEventListener('click', () => { try { saveDataToLocalStorage({ emojiGroups: userscriptState.emojiGroups }) alert('已保存') } catch (e) { alert('保存失败:' + e) } }) syncBtn.addEventListener('click', () => { try { if (syncFromManager()) { alert('同步成功,已导入管理器数据') loadDataFromLocalStorage$1() renderGroups() renderSelectedGroup() } else alert('同步未成功,未检测到管理器数据') } catch (e) { alert('同步异常:' + e) } }) closeBtn.addEventListener('click', () => modal.remove()) modal.addEventListener('click', e => { if (e.target === modal) modal.remove() }) renderGroups() if (userscriptState.emojiGroups.length > 0) { selectedGroupId = userscriptState.emojiGroups[0].id const first = groupsList.firstChild if (first) first.style.background = '#f0f8ff' renderSelectedGroup() } } function loadDataFromLocalStorage$1() { console.log('Data reload requested') } const init_manager = __esmMin(() => { init_styles() init_createEl() init_userscript_storage() }) function showGroupEditorModal() { injectGlobalThemeStyles() const modal = createEl('div', { style: ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); z-index: 999999; display: flex; align-items: center; justify-content: center; ` }) const content = createEl('div', { style: ` background: var(--emoji-modal-bg); color: var(--emoji-modal-text); border-radius: 8px; padding: 24px; max-width: 700px; max-height: 80vh; overflow-y: auto; position: relative; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); ` }) content.innerHTML = ` <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <h2 style="margin: 0; color: var(--emoji-modal-text);">表情分组编辑器</h2> <button id="closeModal" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #999;">×</button> </div> <div style="margin-bottom: 20px; padding: 16px; background: var(--emoji-modal-button-bg); border-radius: 6px; border: 1px solid var(--emoji-modal-border);"> <div style="font-weight: 500; color: var(--emoji-modal-label); margin-bottom: 8px;">编辑说明</div> <div style="font-size: 14px; color: var(--emoji-modal-text); opacity: 0.8; line-height: 1.4;"> • 点击分组名称或图标进行编辑<br> • 图标支持 emoji 字符或单个字符<br> • 修改会立即保存到本地存储<br> • 可以调整分组的显示顺序 </div> </div> <div id="groupsList" style="display: flex; flex-direction: column; gap: 12px;"> ${userscriptState.emojiGroups .map( (group, index) => ` <div class="group-item" data-group-id="${group.id}" data-index="${index}" style=" display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--emoji-modal-button-bg); border: 1px solid var(--emoji-modal-border); border-radius: 6px; transition: all 0.2s; "> <div class="drag-handle" style=" cursor: grab; color: var(--emoji-modal-text); opacity: 0.5; font-size: 16px; user-select: none; " title="拖拽调整顺序">⋮⋮</div> <div class="group-icon-editor" style=" min-width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; background: var(--emoji-modal-bg); border: 1px dashed var(--emoji-modal-border); border-radius: 4px; cursor: pointer; font-size: 18px; user-select: none; " data-group-id="${group.id}" title="点击编辑图标"> ${group.icon || '📁'} </div> <div style="flex: 1; display: flex; flex-direction: column; gap: 4px;"> <input class="group-name-editor" type="text" value="${group.name || 'Unnamed Group'}" data-group-id="${group.id}" style=" background: var(--emoji-modal-bg); color: var(--emoji-modal-text); border: 1px solid var(--emoji-modal-border); border-radius: 4px; padding: 8px 12px; font-size: 14px; font-weight: 500; " placeholder="分组名称"> <div style="font-size: 12px; color: var(--emoji-modal-text); opacity: 0.6;"> ID: ${group.id} | 表情数: ${group.emojis ? group.emojis.length : 0} </div> </div> <div style="display: flex; flex-direction: column; gap: 4px; align-items: center;"> <button class="move-up" data-index="${index}" style=" background: var(--emoji-modal-button-bg); border: 1px solid var(--emoji-modal-border); border-radius: 3px; padding: 4px 8px; cursor: pointer; font-size: 12px; color: var(--emoji-modal-text); " ${index === 0 ? 'disabled' : ''}>↑</button> <button class="move-down" data-index="${index}" style=" background: var(--emoji-modal-button-bg); border: 1px solid var(--emoji-modal-border); border-radius: 3px; padding: 4px 8px; cursor: pointer; font-size: 12px; color: var(--emoji-modal-text); " ${index === userscriptState.emojiGroups.length - 1 ? 'disabled' : ''}>↓</button> </div> </div> ` ) .join('')} </div> <div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--emoji-modal-border); display: flex; gap: 8px; justify-content: flex-end;"> <button id="addNewGroup" style="padding: 8px 16px; background: var(--emoji-modal-primary-bg); color: white; border: none; border-radius: 4px; cursor: pointer;">新建分组</button> <button id="saveAllChanges" style="padding: 8px 16px; background: var(--emoji-modal-primary-bg); color: white; border: none; border-radius: 4px; cursor: pointer;">保存所有更改</button> </div> ` modal.appendChild(content) document.body.appendChild(modal) const style = document.createElement('style') style.textContent = ` .group-item:hover { border-color: var(--emoji-modal-primary-bg) !important; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); } .group-icon-editor:hover { background: var(--emoji-modal-primary-bg) !important; color: white; } .move-up:hover, .move-down:hover { background: var(--emoji-modal-primary-bg) !important; color: white; } .move-up:disabled, .move-down:disabled { opacity: 0.3; cursor: not-allowed !important; } ` document.head.appendChild(style) content.querySelector('#closeModal')?.addEventListener('click', () => { modal.remove() style.remove() }) modal.addEventListener('click', e => { if (e.target === modal) { modal.remove() style.remove() } }) content.querySelectorAll('.group-name-editor').forEach(input => { input.addEventListener('change', e => { const target = e.target const groupId = target.getAttribute('data-group-id') const newName = target.value.trim() if (groupId && newName) { const group = userscriptState.emojiGroups.find(g => g.id === groupId) if (group) { group.name = newName showTemporaryMessage$1(`分组 "${newName}" 名称已更新`) } } }) }) content.querySelectorAll('.group-icon-editor').forEach(iconEl => { iconEl.addEventListener('click', e => { const target = e.target const groupId = target.getAttribute('data-group-id') if (groupId) { const newIcon = prompt( '请输入新的图标字符 (emoji 或单个字符):', target.textContent || '📁' ) if (newIcon && newIcon.trim()) { const group = userscriptState.emojiGroups.find(g => g.id === groupId) if (group) { group.icon = newIcon.trim() target.textContent = newIcon.trim() showTemporaryMessage$1(`分组图标已更新为: ${newIcon.trim()}`) } } } }) }) content.querySelectorAll('.move-up').forEach(btn => { btn.addEventListener('click', e => { const index = parseInt(e.target.getAttribute('data-index') || '0') if (index > 0) { const temp = userscriptState.emojiGroups[index] userscriptState.emojiGroups[index] = userscriptState.emojiGroups[index - 1] userscriptState.emojiGroups[index - 1] = temp modal.remove() style.remove() showTemporaryMessage$1('分组顺序已调整') setTimeout(() => showGroupEditorModal(), 300) } }) }) content.querySelectorAll('.move-down').forEach(btn => { btn.addEventListener('click', e => { const index = parseInt(e.target.getAttribute('data-index') || '0') if (index < userscriptState.emojiGroups.length - 1) { const temp = userscriptState.emojiGroups[index] userscriptState.emojiGroups[index] = userscriptState.emojiGroups[index + 1] userscriptState.emojiGroups[index + 1] = temp modal.remove() style.remove() showTemporaryMessage$1('分组顺序已调整') setTimeout(() => showGroupEditorModal(), 300) } }) }) content.querySelector('#addNewGroup')?.addEventListener('click', () => { const groupName = prompt('请输入新分组的名称:') if (groupName && groupName.trim()) { const newGroup = { id: 'custom_' + Date.now(), name: groupName.trim(), icon: '📁', order: userscriptState.emojiGroups.length, emojis: [] } userscriptState.emojiGroups.push(newGroup) modal.remove() style.remove() showTemporaryMessage$1(`新分组 "${groupName.trim()}" 已创建`) setTimeout(() => showGroupEditorModal(), 300) } }) content.querySelector('#saveAllChanges')?.addEventListener('click', () => { saveDataToLocalStorage({ emojiGroups: userscriptState.emojiGroups }) showTemporaryMessage$1('所有更改已保存到本地存储') }) } function showTemporaryMessage$1(message) { const messageEl = createEl('div', { style: ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--emoji-modal-primary-bg); color: white; padding: 12px 24px; border-radius: 6px; z-index: 9999999; font-size: 14px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); animation: fadeInOut 2s ease-in-out; `, text: message }) if (!document.querySelector('#tempMessageStyles')) { const style = document.createElement('style') style.id = 'tempMessageStyles' style.textContent = ` @keyframes fadeInOut { 0%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } 20%, 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); } } ` document.head.appendChild(style) } document.body.appendChild(messageEl) setTimeout(() => { messageEl.remove() }, 2e3) } const init_groupEditor = __esmMin(() => { init_state() init_userscript_storage() init_createEl() init_themeSupport() }) function showPopularEmojisModal() { injectGlobalThemeStyles() const modal = createEl('div', { style: ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); z-index: 999999; display: flex; align-items: center; justify-content: center; ` }) const content = createEl('div', { style: ` background: var(--emoji-modal-bg); color: var(--emoji-modal-text); border-radius: 8px; padding: 24px; max-width: 600px; max-height: 80vh; overflow-y: auto; position: relative; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); ` }) const popularEmojis = getPopularEmojis(50) content.innerHTML = ` <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> <h2 style="margin: 0; color: var(--emoji-modal-text);">常用表情 (${popularEmojis.length})</h2> <div style="display: flex; gap: 8px; align-items: center;"> <button id="clearStats" style="padding: 6px 12px; background: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">清空统计</button> <button id="closeModal" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #999;">×</button> </div> </div> <div style="margin-bottom: 16px; padding: 12px; background: var(--emoji-modal-button-bg); border-radius: 6px; border: 1px solid var(--emoji-modal-border);"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <span style="font-weight: 500; color: var(--emoji-modal-label);">表情按使用次数排序</span> <span style="font-size: 12px; color: var(--emoji-modal-text); opacity: 0.7;">点击表情直接使用</span> </div> <div style="font-size: 12px; color: var(--emoji-modal-text); opacity: 0.6;"> 总使用次数: ${popularEmojis.reduce((sum, emoji) => sum + emoji.count, 0)} </div> </div> <div id="popularEmojiGrid" style=" display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; max-height: 400px; overflow-y: auto; "> ${ popularEmojis.length === 0 ? '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--emoji-modal-text); opacity: 0.7;">还没有使用过表情<br><small>开始使用表情后,这里会显示常用的表情</small></div>' : popularEmojis .map( emoji => ` <div class="popular-emoji-item" data-name="${emoji.name}" data-url="${emoji.url}" style=" display: flex; flex-direction: column; align-items: center; padding: 8px; border: 1px solid var(--emoji-modal-border); border-radius: 6px; cursor: pointer; transition: all 0.2s; background: var(--emoji-modal-button-bg); "> <img src="${emoji.url}" alt="${emoji.name}" style=" width: 40px; height: 40px; object-fit: contain; margin-bottom: 4px; "> <div style=" font-size: 11px; font-weight: 500; color: var(--emoji-modal-text); text-align: center; word-break: break-all; line-height: 1.2; margin-bottom: 2px; ">${emoji.name}</div> <div style=" font-size: 10px; color: var(--emoji-modal-text); opacity: 0.6; text-align: center; ">使用${emoji.count}次</div> </div> ` ) .join('') } </div> ${ popularEmojis.length > 0 ? ` <div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--emoji-modal-border); font-size: 12px; color: var(--emoji-modal-text); opacity: 0.6; text-align: center;"> 统计数据保存在本地,清空统计将重置所有使用记录 </div> ` : '' } ` modal.appendChild(content) document.body.appendChild(modal) const style = document.createElement('style') style.textContent = ` .popular-emoji-item:hover { transform: translateY(-2px); border-color: var(--emoji-modal-primary-bg) !important; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } ` document.head.appendChild(style) content.querySelector('#closeModal')?.addEventListener('click', () => { modal.remove() style.remove() }) content.querySelector('#clearStats')?.addEventListener('click', () => { if (confirm('确定要清空所有表情使用统计吗?此操作不可撤销。')) { clearEmojiUsageStats() modal.remove() style.remove() showTemporaryMessage('表情使用统计已清空') setTimeout(() => showPopularEmojisModal(), 300) } }) content.querySelectorAll('.popular-emoji-item').forEach(item => { item.addEventListener('click', () => { const name = item.getAttribute('data-name') const url = item.getAttribute('data-url') if (name && url) { trackEmojiUsage(name, url) useEmojiFromPopular(name, url) modal.remove() style.remove() showTemporaryMessage(`已使用表情: ${name}`) } }) }) modal.addEventListener('click', e => { if (e.target === modal) { modal.remove() style.remove() } }) } function useEmojiFromPopular(name, url) { const activeElement = document.activeElement if ( activeElement && (activeElement.tagName === 'TEXTAREA' || activeElement.tagName === 'INPUT') ) { const textArea = activeElement const format = userscriptState.settings.outputFormat let emojiText = '' if (format === 'markdown') emojiText = `` else emojiText = `<img src="${url}" alt="${name}" style="width: ${userscriptState.settings.imageScale}px; height: ${userscriptState.settings.imageScale}px;">` const start = textArea.selectionStart || 0 const end = textArea.selectionEnd || 0 const currentValue = textArea.value textArea.value = currentValue.slice(0, start) + emojiText + currentValue.slice(end) const newPosition = start + emojiText.length textArea.setSelectionRange(newPosition, newPosition) textArea.dispatchEvent(new Event('input', { bubbles: true })) textArea.focus() } else { const textAreas = document.querySelectorAll( 'textarea, input[type="text"], [contenteditable="true"]' ) const lastTextArea = Array.from(textAreas).pop() if (lastTextArea) { lastTextArea.focus() if (lastTextArea.tagName === 'TEXTAREA' || lastTextArea.tagName === 'INPUT') { const format = userscriptState.settings.outputFormat let emojiText = '' if (format === 'markdown') emojiText = `` else emojiText = `<img src="${url}" alt="${name}" style="width: ${userscriptState.settings.imageScale}px; height: ${userscriptState.settings.imageScale}px;">` const textarea = lastTextArea textarea.value += emojiText textarea.dispatchEvent(new Event('input', { bubbles: true })) } } } } function showTemporaryMessage(message) { const messageEl = createEl('div', { style: ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--emoji-modal-primary-bg); color: white; padding: 12px 24px; border-radius: 6px; z-index: 9999999; font-size: 14px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); animation: fadeInOut 2s ease-in-out; `, text: message }) if (!document.querySelector('#tempMessageStyles')) { const style = document.createElement('style') style.id = 'tempMessageStyles' style.textContent = ` @keyframes fadeInOut { 0%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); } 20%, 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); } } ` document.head.appendChild(style) } document.body.appendChild(messageEl) setTimeout(() => { messageEl.remove() }, 2e3) } const init_popularEmojis = __esmMin(() => { init_state() init_userscript_storage() init_createEl() init_themeSupport() }) const settings_exports = /* @__PURE__ */ __export({ showSettingsModal: () => showSettingsModal }) function showSettingsModal() { injectGlobalThemeStyles() const modal = createEl('div', { style: ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.8); z-index: 999999; display: flex; align-items: center; justify-content: center; ` }) const content = createEl('div', { style: ` background: var(--emoji-modal-bg); color: var(--emoji-modal-text); border-radius: 8px; padding: 24px; max-width: 500px; max-height: 80vh; overflow-y: auto; position: relative; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); `, innerHTML: ` <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> <h2 style="margin: 0; color: var(--emoji-modal-text);">设置</h2> <button id="closeModal" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #999;">×</button> </div> <div style="margin-bottom: 16px;"> <label style="display: block; margin-bottom: 8px; color: var(--emoji-modal-label); font-weight: 500;">图片缩放比例: <span id="scaleValue">${userscriptState.settings.imageScale}%</span></label> <input type="range" id="scaleSlider" min="5" max="150" step="5" value="${userscriptState.settings.imageScale}" style="width: 100%; margin-bottom: 8px;"> </div> <div style="margin-bottom: 16px;"> <label style="display: block; margin-bottom: 8px; color: var(--emoji-modal-label); font-weight: 500;">输出格式:</label> <div style="display: flex; gap: 16px;"> <label style="display: flex; align-items: center; color: var(--emoji-modal-text);"> <input type="radio" name="outputFormat" value="markdown" ${userscriptState.settings.outputFormat === 'markdown' ? 'checked' : ''} style="margin-right: 4px;"> Markdown </label> <label style="display: flex; align-items: center; color: var(--emoji-modal-text);"> <input type="radio" name="outputFormat" value="html" ${userscriptState.settings.outputFormat === 'html' ? 'checked' : ''} style="margin-right: 4px;"> HTML </label> </div> </div> <div style="margin-bottom: 16px;"> <label style="display: flex; align-items: center; color: var(--emoji-modal-label); font-weight: 500;"> <input type="checkbox" id="showSearchBar" ${userscriptState.settings.showSearchBar ? 'checked' : ''} style="margin-right: 8px;"> 显示搜索栏 </label> </div> <div style="margin-bottom: 16px;"> <label style="display: flex; align-items: center; color: var(--emoji-modal-label); font-weight: 500;"> <input type="checkbox" id="enableFloatingPreview" ${userscriptState.settings.enableFloatingPreview ? 'checked' : ''} style="margin-right: 8px;"> 启用悬浮预览功能 </label> </div> <div style="margin-bottom: 16px;"> <label style="display: flex; align-items: center; color: var(--emoji-modal-label); font-weight: 500;"> <input type="checkbox" id="forceMobileMode" ${userscriptState.settings.forceMobileMode ? 'checked' : ''} style="margin-right: 8px;"> 强制移动模式 (在不兼容检测时也注入移动版布局) </label> </div> <div style="margin-bottom: 16px; padding: 12px; background: var(--emoji-modal-button-bg); border-radius: 6px; border: 1px solid var(--emoji-modal-border);"> <div style="font-weight: 500; color: var(--emoji-modal-label); margin-bottom: 8px;">高级功能</div> <div style="display: flex; gap: 8px; flex-wrap: wrap;"> <button id="openGroupEditor" style=" padding: 6px 12px; background: var(--emoji-modal-primary-bg); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; ">编辑分组</button> <button id="openPopularEmojis" style=" padding: 6px 12px; background: var(--emoji-modal-primary-bg); color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; ">常用表情</button> </div> </div> <div style="display: flex; gap: 8px; justify-content: flex-end;"> <button id="resetSettings" style="padding: 8px 16px; background: var(--emoji-modal-button-bg); color: var(--emoji-modal-text); border: 1px solid var(--emoji-modal-border); border-radius: 4px; cursor: pointer;">重置</button> <button id="saveSettings" style="padding: 8px 16px; background: var(--emoji-modal-primary-bg); color: white; border: none; border-radius: 4px; cursor: pointer;">保存</button> </div> ` }) modal.appendChild(content) document.body.appendChild(modal) const scaleSlider = content.querySelector('#scaleSlider') const scaleValue = content.querySelector('#scaleValue') scaleSlider?.addEventListener('input', () => { if (scaleValue) scaleValue.textContent = scaleSlider.value + '%' }) content.querySelector('#closeModal')?.addEventListener('click', () => { modal.remove() }) content.querySelector('#resetSettings')?.addEventListener('click', async () => { if (confirm('确定要重置所有设置吗?')) { userscriptState.settings = { imageScale: 30, gridColumns: 4, outputFormat: 'markdown', forceMobileMode: false, defaultGroup: 'nachoneko', showSearchBar: true, enableFloatingPreview: true } modal.remove() } }) content.querySelector('#saveSettings')?.addEventListener('click', () => { userscriptState.settings.imageScale = parseInt(scaleSlider?.value || '30') const outputFormat = content.querySelector('input[name="outputFormat"]:checked') if (outputFormat) userscriptState.settings.outputFormat = outputFormat.value const showSearchBar = content.querySelector('#showSearchBar') if (showSearchBar) userscriptState.settings.showSearchBar = showSearchBar.checked const enableFloatingPreview = content.querySelector('#enableFloatingPreview') if (enableFloatingPreview) userscriptState.settings.enableFloatingPreview = enableFloatingPreview.checked const forceMobileEl = content.querySelector('#forceMobileMode') if (forceMobileEl) userscriptState.settings.forceMobileMode = !!forceMobileEl.checked saveDataToLocalStorage({ settings: userscriptState.settings }) try { const remoteInput = content.querySelector('#remoteConfigUrl') if (remoteInput && remoteInput.value.trim()) localStorage.setItem('emoji_extension_remote_config_url', remoteInput.value.trim()) } catch (e) {} alert('设置已保存') modal.remove() }) content.querySelector('#openGroupEditor')?.addEventListener('click', () => { modal.remove() showGroupEditorModal() }) content.querySelector('#openPopularEmojis')?.addEventListener('click', () => { modal.remove() showPopularEmojisModal() }) modal.addEventListener('click', e => { if (e.target === modal) modal.remove() }) } const init_settings = __esmMin(() => { init_state() init_userscript_storage() init_createEl() init_themeSupport() init_groupEditor() init_popularEmojis() }) init_state() init_userscript_storage() init_createEl() function isMobileView() { try { return ( getEffectivePlatform() === 'mobile' || !!( userscriptState && userscriptState.settings && userscriptState.settings.forceMobileMode ) ) } catch (e) { return false } } function insertEmojiIntoEditor(emoji) { console.log('[Emoji Extension Userscript] Inserting emoji:', emoji) if (emoji.name && emoji.url) trackEmojiUsage(emoji.name, emoji.url) const textarea = document.querySelector('textarea.d-editor-input') const proseMirror = document.querySelector('.ProseMirror.d-editor-input') if (!textarea && !proseMirror) { console.error('找不到输入框') return } const dimensionMatch = emoji.url?.match(/_(\d{3,})x(\d{3,})\./) let width = '500' let height = '500' if (dimensionMatch) { width = dimensionMatch[1] height = dimensionMatch[2] } else if (emoji.width && emoji.height) { width = emoji.width.toString() height = emoji.height.toString() } const scale = userscriptState.settings?.imageScale || 30 const outputFormat = userscriptState.settings?.outputFormat || 'markdown' if (textarea) { let insertText = '' if (outputFormat === 'html') { const scaledWidth = Math.max(1, Math.round(Number(width) * (scale / 100))) const scaledHeight = Math.max(1, Math.round(Number(height) * (scale / 100))) insertText = `<img src="${emoji.url}" title=":${emoji.name}:" class="emoji only-emoji" alt=":${emoji.name}:" loading="lazy" width="${scaledWidth}" height="${scaledHeight}" style="aspect-ratio: ${scaledWidth} / ${scaledHeight};"> ` } else insertText = ` ` const selectionStart = textarea.selectionStart const selectionEnd = textarea.selectionEnd textarea.value = textarea.value.substring(0, selectionStart) + insertText + textarea.value.substring(selectionEnd, textarea.value.length) textarea.selectionStart = textarea.selectionEnd = selectionStart + insertText.length textarea.focus() const inputEvent = new Event('input', { bubbles: true, cancelable: true }) textarea.dispatchEvent(inputEvent) } else if (proseMirror) { const imgWidth = Number(width) || 500 const scaledWidth = Math.max(1, Math.round(imgWidth * (scale / 100))) const htmlContent = `<img src="${emoji.url}" alt="${emoji.name}" width="${width}" height="${height}" data-scale="${scale}" style="width: ${scaledWidth}px">` try { const dataTransfer = new DataTransfer() dataTransfer.setData('text/html', htmlContent) const pasteEvent = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true }) proseMirror.dispatchEvent(pasteEvent) } catch (error) { try { document.execCommand('insertHTML', false, htmlContent) } catch (fallbackError) { console.error('无法向富文本编辑器中插入表情', fallbackError) } } } } let _hoverPreviewEl = null function ensureHoverPreview() { if (_hoverPreviewEl && document.body.contains(_hoverPreviewEl)) return _hoverPreviewEl _hoverPreviewEl = createEl('div', { className: 'emoji-picker-hover-preview', style: 'position:fixed;pointer-events:none;display:none;z-index:1000002;max-width:300px;max-height:300px;overflow:hidden;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.25);background:#fff;padding:6px;' }) const img = createEl('img', { className: 'emoji-picker-hover-img', style: 'display:block;max-width:100%;max-height:220px;object-fit:contain;' }) const label = createEl('div', { className: 'emoji-picker-hover-label', style: 'font-size:12px;color:#333;margin-top:6px;text-align:center;' }) _hoverPreviewEl.appendChild(img) _hoverPreviewEl.appendChild(label) document.body.appendChild(_hoverPreviewEl) return _hoverPreviewEl } function createMobileEmojiPicker(groups) { const modal = createEl('div', { className: 'modal d-modal fk-d-menu-modal emoji-picker-content', attrs: { 'data-identifier': 'emoji-picker', 'data-keyboard': 'false', 'aria-modal': 'true', role: 'dialog' } }) const modalContainerDiv = createEl('div', { className: 'd-modal__container' }) const modalBody = createEl('div', { className: 'd-modal__body' }) modalBody.tabIndex = -1 const emojiPickerDiv = createEl('div', { className: 'emoji-picker' }) const filterContainer = createEl('div', { className: 'emoji-picker__filter-container' }) const filterInputContainer = createEl('div', { className: 'emoji-picker__filter filter-input-container' }) const filterInput = createEl('input', { className: 'filter-input', placeholder: '按表情符号名称搜索…', type: 'text' }) filterInputContainer.appendChild(filterInput) const closeButton = createEl('button', { className: 'btn no-text btn-icon btn-transparent emoji-picker__close-btn', type: 'button', innerHTML: `<svg class="fa d-icon d-icon-xmark svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#xmark"></use></svg>` }) closeButton.addEventListener('click', () => { const container = modal.closest('.modal-container') || modal if (container) container.remove() }) filterContainer.appendChild(filterInputContainer) filterContainer.appendChild(closeButton) const content = createEl('div', { className: 'emoji-picker__content' }) const sectionsNav = createEl('div', { className: 'emoji-picker__sections-nav' }) const managementButton = createEl('button', { className: 'btn no-text btn-flat emoji-picker__section-btn management-btn', attrs: { tabindex: '-1', style: 'border-right: 1px solid #ddd;' }, innerHTML: '⚙️', title: '管理表情 - 点击打开完整管理界面', type: 'button' }) managementButton.addEventListener('click', () => { __vitePreload( async () => { const { openManagementInterface: openManagementInterface$1 } = await Promise.resolve().then(() => (init_manager(), manager_exports)) return { openManagementInterface: openManagementInterface$1 } }, void 0 ).then(({ openManagementInterface: openManagementInterface$1 }) => { openManagementInterface$1() }) }) sectionsNav.appendChild(managementButton) const settingsButton = createEl('button', { className: 'btn no-text btn-flat emoji-picker__section-btn settings-btn', innerHTML: '🔧', title: '设置', attrs: { tabindex: '-1', style: 'border-right: 1px solid #ddd;' }, type: 'button' }) settingsButton.addEventListener('click', () => { __vitePreload( async () => { const { showSettingsModal: showSettingsModal$1 } = await Promise.resolve().then( () => (init_settings(), settings_exports) ) return { showSettingsModal: showSettingsModal$1 } }, void 0 ).then(({ showSettingsModal: showSettingsModal$1 }) => { showSettingsModal$1() }) }) sectionsNav.appendChild(settingsButton) const scrollableContent = createEl('div', { className: 'emoji-picker__scrollable-content' }) const sections = createEl('div', { className: 'emoji-picker__sections', attrs: { role: 'button' } }) let hoverPreviewEl = null function ensureHoverPreview$1() { if (hoverPreviewEl && document.body.contains(hoverPreviewEl)) return hoverPreviewEl hoverPreviewEl = createEl('div', { className: 'emoji-picker-hover-preview', style: 'position:fixed;pointer-events:none;display:none;z-index:1000002;max-width:300px;max-height:300px;overflow:hidden;border-radius:6px;box-shadow:0 4px 12px rgba(0,0,0,0.25);background:#fff;padding:6px;' }) const img = createEl('img', { className: 'emoji-picker-hover-img', style: 'display:block;max-width:100%;max-height:220px;object-fit:contain;' }) const label = createEl('div', { className: 'emoji-picker-hover-label', style: 'font-size:12px;color:#333;margin-top:6px;text-align:center;' }) hoverPreviewEl.appendChild(img) hoverPreviewEl.appendChild(label) document.body.appendChild(hoverPreviewEl) return hoverPreviewEl } groups.forEach((group, index) => { if (!group?.emojis?.length) return const navButton = createEl('button', { className: `btn no-text btn-flat emoji-picker__section-btn ${index === 0 ? 'active' : ''}`, attrs: { tabindex: '-1', 'data-section': group.id, type: 'button' } }) const iconVal = group.icon || '📁' if (isImageUrl(iconVal)) { const img = createEl('img', { src: iconVal, alt: group.name || '', className: 'emoji', style: 'width: 18px; height: 18px; object-fit: contain;' }) navButton.appendChild(img) } else navButton.textContent = String(iconVal) navButton.title = group.name navButton.addEventListener('click', () => { sectionsNav .querySelectorAll('.emoji-picker__section-btn') .forEach(btn => btn.classList.remove('active')) navButton.classList.add('active') const target = sections.querySelector(`[data-section="${group.id}"]`) if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }) }) sectionsNav.appendChild(navButton) const section = createEl('div', { className: 'emoji-picker__section', attrs: { 'data-section': group.id, role: 'region', 'aria-label': group.name } }) const titleContainer = createEl('div', { className: 'emoji-picker__section-title-container' }) const title = createEl('h2', { className: 'emoji-picker__section-title', text: group.name }) titleContainer.appendChild(title) const sectionEmojis = createEl('div', { className: 'emoji-picker__section-emojis' }) group.emojis.forEach(emoji => { if (!emoji || typeof emoji !== 'object' || !emoji.url || !emoji.name) return const img = createEl('img', { src: emoji.url, alt: emoji.name, className: 'emoji', title: `:${emoji.name}:`, style: 'width: 32px; height: 32px; object-fit: contain;', attrs: { 'data-emoji': emoji.name, tabindex: '0', loading: 'lazy' } }) ;(function bindHover(imgEl, emo) { if (!userscriptState.settings?.enableFloatingPreview) return const preview = ensureHoverPreview$1() const previewImg = preview.querySelector('img') const previewLabel = preview.querySelector('.emoji-picker-hover-label') let fadeTimer = null function onEnter(e) { previewImg.src = emo.url previewLabel.textContent = emo.name || '' preview.style.display = 'block' preview.style.opacity = '1' preview.style.transition = 'opacity 0.12s ease, transform 0.12s ease' if (fadeTimer) { clearTimeout(fadeTimer) fadeTimer = null } fadeTimer = window.setTimeout(() => { preview.style.opacity = '0' setTimeout(() => { if (preview.style.opacity === '0') preview.style.display = 'none' }, 300) }, 5e3) move(e) } function move(e) { const pad = 12 const vw = window.innerWidth const vh = window.innerHeight const rect = preview.getBoundingClientRect() let left = e.clientX + pad let top = e.clientY + pad if (left + rect.width > vw) left = e.clientX - rect.width - pad if (top + rect.height > vh) top = e.clientY - rect.height - pad preview.style.left = left + 'px' preview.style.top = top + 'px' } function onLeave() { if (fadeTimer) { clearTimeout(fadeTimer) fadeTimer = null } preview.style.display = 'none' } imgEl.addEventListener('mouseenter', onEnter) imgEl.addEventListener('mousemove', move) imgEl.addEventListener('mouseleave', onLeave) })(img, emoji) img.addEventListener('click', () => { insertEmojiIntoEditor(emoji) const modalContainer = modal.closest('.modal-container') if (modalContainer) modalContainer.remove() else modal.remove() }) img.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() insertEmojiIntoEditor(emoji) const modalContainer = modal.closest('.modal-container') if (modalContainer) modalContainer.remove() else modal.remove() } }) sectionEmojis.appendChild(img) }) section.appendChild(titleContainer) section.appendChild(sectionEmojis) sections.appendChild(section) }) filterInput.addEventListener('input', e => { const q = (e.target.value || '').toLowerCase() sections.querySelectorAll('img').forEach(img => { const emojiName = (img.dataset.emoji || '').toLowerCase() img.style.display = q === '' || emojiName.includes(q) ? '' : 'none' }) sections.querySelectorAll('.emoji-picker__section').forEach(section => { const visibleEmojis = section.querySelectorAll('img:not([style*="display: none"])') section.style.display = visibleEmojis.length > 0 ? '' : 'none' }) }) scrollableContent.appendChild(sections) content.appendChild(sectionsNav) content.appendChild(scrollableContent) emojiPickerDiv.appendChild(filterContainer) emojiPickerDiv.appendChild(content) modalBody.appendChild(emojiPickerDiv) modalContainerDiv.appendChild(modalBody) modal.appendChild(modalContainerDiv) return modal } function createDesktopEmojiPicker(groups) { const picker = createEl('div', { className: 'fk-d-menu -animated -expanded', style: 'max-width: 400px; visibility: visible; z-index: 999999;', attrs: { 'data-identifier': 'emoji-picker', role: 'dialog' } }) const innerContent = createEl('div', { className: 'fk-d-menu__inner-content' }) const emojiPickerDiv = createEl('div', { className: 'emoji-picker' }) const filterContainer = createEl('div', { className: 'emoji-picker__filter-container' }) const filterDiv = createEl('div', { className: 'emoji-picker__filter filter-input-container' }) const searchInput = createEl('input', { className: 'filter-input', placeholder: '按表情符号名称搜索…', type: 'text' }) filterDiv.appendChild(searchInput) filterContainer.appendChild(filterDiv) const content = createEl('div', { className: 'emoji-picker__content' }) const sectionsNav = createEl('div', { className: 'emoji-picker__sections-nav' }) const managementButton = createEl('button', { className: 'btn no-text btn-flat emoji-picker__section-btn management-btn', attrs: { tabindex: '-1', style: 'border-right: 1px solid #ddd;' }, type: 'button', innerHTML: '⚙️', title: '管理表情 - 点击打开完整管理界面' }) managementButton.addEventListener('click', () => { __vitePreload( async () => { const { openManagementInterface: openManagementInterface$1 } = await Promise.resolve().then(() => (init_manager(), manager_exports)) return { openManagementInterface: openManagementInterface$1 } }, void 0 ).then(({ openManagementInterface: openManagementInterface$1 }) => { openManagementInterface$1() }) }) sectionsNav.appendChild(managementButton) const settingsButton = createEl('button', { className: 'btn no-text btn-flat emoji-picker__section-btn settings-btn', attrs: { tabindex: '-1', style: 'border-right: 1px solid #ddd;' }, type: 'button', innerHTML: '🔧', title: '设置' }) settingsButton.addEventListener('click', () => { __vitePreload( async () => { const { showSettingsModal: showSettingsModal$1 } = await Promise.resolve().then( () => (init_settings(), settings_exports) ) return { showSettingsModal: showSettingsModal$1 } }, void 0 ).then(({ showSettingsModal: showSettingsModal$1 }) => { showSettingsModal$1() }) }) sectionsNav.appendChild(settingsButton) const scrollableContent = createEl('div', { className: 'emoji-picker__scrollable-content' }) const sections = createEl('div', { className: 'emoji-picker__sections', attrs: { role: 'button' } }) groups.forEach((group, index) => { if (!group?.emojis?.length) return const navButton = createEl('button', { className: `btn no-text btn-flat emoji-picker__section-btn ${index === 0 ? 'active' : ''}`, attrs: { tabindex: '-1', 'data-section': group.id }, type: 'button' }) const iconVal = group.icon || '📁' if (isImageUrl(iconVal)) { const img = createEl('img', { src: iconVal, alt: group.name || '', className: 'emoji-group-icon', style: 'width: 18px; height: 18px; object-fit: contain;' }) navButton.appendChild(img) } else navButton.textContent = String(iconVal) navButton.title = group.name navButton.addEventListener('click', () => { sectionsNav .querySelectorAll('.emoji-picker__section-btn') .forEach(btn => btn.classList.remove('active')) navButton.classList.add('active') const target = sections.querySelector(`[data-section="${group.id}"]`) if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' }) }) sectionsNav.appendChild(navButton) const section = createEl('div', { className: 'emoji-picker__section', attrs: { 'data-section': group.id, role: 'region', 'aria-label': group.name } }) const titleContainer = createEl('div', { className: 'emoji-picker__section-title-container' }) const title = createEl('h2', { className: 'emoji-picker__section-title', text: group.name }) titleContainer.appendChild(title) const sectionEmojis = createEl('div', { className: 'emoji-picker__section-emojis' }) let added = 0 group.emojis.forEach(emoji => { if (!emoji || typeof emoji !== 'object' || !emoji.url || !emoji.name) return const img = createEl('img', { width: '32px', height: '32px', className: 'emoji', src: emoji.url, alt: emoji.name, title: `:${emoji.name}:`, attrs: { 'data-emoji': emoji.name, tabindex: '0', loading: 'lazy' } }) ;(function bindHover(imgEl, emo) { if (!userscriptState.settings?.enableFloatingPreview) return const preview = ensureHoverPreview() const previewImg = preview.querySelector('img') const previewLabel = preview.querySelector('.emoji-picker-hover-label') let fadeTimer = null function onEnter(e) { previewImg.src = emo.url previewLabel.textContent = emo.name || '' preview.style.display = 'block' preview.style.opacity = '1' preview.style.transition = 'opacity 0.12s ease, transform 0.12s ease' if (fadeTimer) { clearTimeout(fadeTimer) fadeTimer = null } fadeTimer = window.setTimeout(() => { preview.style.opacity = '0' setTimeout(() => { if (preview.style.opacity === '0') preview.style.display = 'none' }, 300) }, 5e3) move(e) } function move(e) { const pad = 12 const vw = window.innerWidth const vh = window.innerHeight const rect = preview.getBoundingClientRect() let left = e.clientX + pad let top = e.clientY + pad if (left + rect.width > vw) left = e.clientX - rect.width - pad if (top + rect.height > vh) top = e.clientY - rect.height - pad preview.style.left = left + 'px' preview.style.top = top + 'px' } function onLeave() { if (fadeTimer) { clearTimeout(fadeTimer) fadeTimer = null } preview.style.display = 'none' } imgEl.addEventListener('mouseenter', onEnter) imgEl.addEventListener('mousemove', move) imgEl.addEventListener('mouseleave', onLeave) })(img, emoji) img.addEventListener('click', () => { insertEmojiIntoEditor(emoji) picker.remove() }) img.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() insertEmojiIntoEditor(emoji) picker.remove() } }) sectionEmojis.appendChild(img) added++ }) if (added === 0) { const msg = createEl('div', { text: `${group.name} 组暂无有效表情`, style: 'padding: 20px; text-align: center; color: #999;' }) sectionEmojis.appendChild(msg) } section.appendChild(titleContainer) section.appendChild(sectionEmojis) sections.appendChild(section) }) searchInput.addEventListener('input', e => { const q = (e.target.value || '').toLowerCase() sections.querySelectorAll('img').forEach(img => { const emojiName = img.getAttribute('data-emoji')?.toLowerCase() || '' img.style.display = q === '' || emojiName.includes(q) ? '' : 'none' }) sections.querySelectorAll('.emoji-picker__section').forEach(section => { const visibleEmojis = section.querySelectorAll('img:not([style*="none"])') const titleContainer = section.querySelector('.emoji-picker__section-title-container') if (titleContainer) titleContainer.style.display = visibleEmojis.length > 0 ? '' : 'none' }) }) scrollableContent.appendChild(sections) content.appendChild(sectionsNav) content.appendChild(scrollableContent) emojiPickerDiv.appendChild(filterContainer) emojiPickerDiv.appendChild(content) innerContent.appendChild(emojiPickerDiv) picker.appendChild(innerContent) return picker } async function createEmojiPicker() { const groups = userscriptState.emojiGroups const mobile = isMobileView() try { injectEmojiPickerStyles() } catch (e) { console.warn('injectEmojiPickerStyles failed', e) } if (mobile) return createMobileEmojiPicker(groups) else return createDesktopEmojiPicker(groups) } init_createEl() init_popularEmojis() function findAllToolbars() { const toolbars = [] const selectors = getPlatformToolbarSelectors() for (const selector of selectors) { const elements = document.querySelectorAll(selector) toolbars.push(...Array.from(elements)) } return toolbars } let currentPicker = null function closeCurrentPicker() { if (currentPicker) { currentPicker.remove() currentPicker = null } } function injectEmojiButton(toolbar) { if (toolbar.querySelector('.emoji-extension-button')) return const isChatComposer = toolbar.classList.contains('chat-composer__inner-container') const button = createEl('button', { className: 'btn no-text btn-icon toolbar__button nacho-emoji-picker-button emoji-extension-button', title: '表情包', type: 'button', innerHTML: '🐈⬛' }) const popularButton = createEl('button', { className: 'btn no-text btn-icon toolbar__button nacho-emoji-popular-button emoji-extension-button', title: '常用表情', type: 'button', innerHTML: '⭐' }) if (isChatComposer) { button.classList.add( 'fk-d-menu__trigger', 'emoji-picker-trigger', 'chat-composer-button', 'btn-transparent', '-emoji' ) button.setAttribute('aria-expanded', 'false') button.setAttribute('data-identifier', 'emoji-picker') button.setAttribute('data-trigger', '') popularButton.classList.add( 'fk-d-menu__trigger', 'popular-emoji-trigger', 'chat-composer-button', 'btn-transparent', '-popular' ) popularButton.setAttribute('aria-expanded', 'false') popularButton.setAttribute('data-identifier', 'popular-emoji') popularButton.setAttribute('data-trigger', '') } button.addEventListener('click', async e => { e.stopPropagation() if (currentPicker) { closeCurrentPicker() return } currentPicker = await createEmojiPicker() if (!currentPicker) return document.body.appendChild(currentPicker) const buttonRect = button.getBoundingClientRect() if ( currentPicker.classList.contains('modal') || currentPicker.className.includes('d-modal') ) { currentPicker.style.position = 'fixed' currentPicker.style.top = '0' currentPicker.style.left = '0' currentPicker.style.right = '0' currentPicker.style.bottom = '0' currentPicker.style.zIndex = '999999' } else { currentPicker.style.position = 'fixed' const margin = 8 const vpWidth = window.innerWidth const vpHeight = window.innerHeight currentPicker.style.top = buttonRect.bottom + margin + 'px' currentPicker.style.left = buttonRect.left + 'px' const pickerRect = currentPicker.getBoundingClientRect() const spaceBelow = vpHeight - buttonRect.bottom const neededHeight = pickerRect.height + margin let top = buttonRect.bottom + margin if (spaceBelow < neededHeight) top = Math.max(margin, buttonRect.top - pickerRect.height - margin) let left = buttonRect.left if (left + pickerRect.width + margin > vpWidth) left = Math.max(margin, vpWidth - pickerRect.width - margin) if (left < margin) left = margin currentPicker.style.top = top + 'px' currentPicker.style.left = left + 'px' } setTimeout(() => { const handleClick = e$1 => { if (currentPicker && !currentPicker.contains(e$1.target) && e$1.target !== button) { closeCurrentPicker() document.removeEventListener('click', handleClick) } } document.addEventListener('click', handleClick) }, 100) }) popularButton.addEventListener('click', e => { e.stopPropagation() closeCurrentPicker() showPopularEmojisModal() }) try { if (isChatComposer) { const existingEmojiTrigger = toolbar.querySelector( '.emoji-picker-trigger:not(.emoji-extension-button)' ) if (existingEmojiTrigger) { toolbar.insertBefore(button, existingEmojiTrigger) toolbar.insertBefore(popularButton, existingEmojiTrigger) } else { toolbar.appendChild(button) toolbar.appendChild(popularButton) } } else { toolbar.appendChild(button) toolbar.appendChild(popularButton) } } catch (error) { console.error('[Emoji Extension Userscript] Failed to inject button:', error) } } function attemptInjection() { const toolbars = findAllToolbars() let injectedCount = 0 toolbars.forEach(toolbar => { if (!toolbar.querySelector('.emoji-extension-button')) { console.log('[Emoji Extension Userscript] Toolbar found, injecting button.') injectEmojiButton(toolbar) injectedCount++ } }) return { injectedCount, totalToolbars: toolbars.length } } function startPeriodicInjection() { setInterval(() => { findAllToolbars().forEach(toolbar => { if (!toolbar.querySelector('.emoji-extension-button')) { console.log('[Emoji Extension Userscript] New toolbar found, injecting button.') injectEmojiButton(toolbar) } }) }, 3e4) } init_createEl() init_themeSupport() let floatingButton = null let isButtonVisible = false const FLOATING_BUTTON_STYLES = ` .emoji-extension-floating-button { position: fixed !important; bottom: 20px !important; right: 20px !important; width: 56px !important; height: 56px !important; border-radius: 50% !important; background: linear-gradient(135deg, var(--emoji-button-gradient-start) 0%, var(--emoji-button-gradient-end) 100%) !important; border: none !important; box-shadow: 0 4px 12px var(--emoji-button-shadow) !important; cursor: pointer !important; z-index: 999999 !important; font-size: 24px !important; color: white !important; display: flex !important; align-items: center !important; justify-content: center !important; transition: all 0.3s ease !important; opacity: 0.9 !important; line-height: 1 !important; } .emoji-extension-floating-button:hover { transform: scale(1.1) !important; opacity: 1 !important; box-shadow: 0 6px 16px var(--emoji-button-hover-shadow) !important; } .emoji-extension-floating-button:active { transform: scale(0.95) !important; } .emoji-extension-floating-button.hidden { opacity: 0 !important; pointer-events: none !important; transform: translateY(20px) !important; } @media (max-width: 768px) { .emoji-extension-floating-button { bottom: 15px !important; right: 15px !important; width: 48px !important; height: 48px !important; font-size: 20px !important; } } ` function injectStyles() { if (document.getElementById('emoji-extension-floating-button-styles')) return injectGlobalThemeStyles() const style = createEl('style', { attrs: { id: 'emoji-extension-floating-button-styles' }, text: FLOATING_BUTTON_STYLES }) document.head.appendChild(style) } function createFloatingButton() { const button = createEl('button', { className: 'emoji-extension-floating-button', title: '手动注入表情按钮 (Manual Emoji Injection)', innerHTML: '🐈⬛' }) button.addEventListener('click', async e => { e.stopPropagation() e.preventDefault() button.style.transform = 'scale(0.9)' button.innerHTML = '⏳' try { const result = attemptInjection() if (result.injectedCount > 0) { button.innerHTML = '✅' button.style.background = 'linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%)' setTimeout(() => { button.innerHTML = '🐈⬛' button.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' button.style.transform = 'scale(1)' }, 1500) console.log( `[Emoji Extension Userscript] Manual injection successful: ${result.injectedCount} buttons injected into ${result.totalToolbars} toolbars` ) } else { button.innerHTML = '❌' button.style.background = 'linear-gradient(135deg, #ff6b6b 0%, #ffa8a8 100%)' setTimeout(() => { button.innerHTML = '🐈⬛' button.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' button.style.transform = 'scale(1)' }, 1500) console.log( '[Emoji Extension Userscript] Manual injection failed: No compatible toolbars found' ) } } catch (error) { button.innerHTML = '⚠️' button.style.background = 'linear-gradient(135deg, #ff6b6b 0%, #ffa8a8 100%)' setTimeout(() => { button.innerHTML = '🐈⬛' button.style.background = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)' button.style.transform = 'scale(1)' }, 1500) console.error('[Emoji Extension Userscript] Manual injection error:', error) } }) return button } function showFloatingButton() { if (floatingButton) return injectStyles() floatingButton = createFloatingButton() document.body.appendChild(floatingButton) isButtonVisible = true console.log('[Emoji Extension Userscript] Floating manual injection button shown') } function hideFloatingButton() { if (floatingButton) { floatingButton.classList.add('hidden') setTimeout(() => { if (floatingButton) { floatingButton.remove() floatingButton = null isButtonVisible = false } }, 300) console.log('[Emoji Extension Userscript] Floating manual injection button hidden') } } function autoShowFloatingButton() { if (!isButtonVisible) { console.log( '[Emoji Extension Userscript] Auto-showing floating button due to injection difficulties' ) showFloatingButton() } } function checkAndShowFloatingButton() { const existingButtons = document.querySelectorAll('.emoji-extension-button') if (existingButtons.length === 0 && !isButtonVisible) setTimeout(() => { autoShowFloatingButton() }, 2e3) else if (existingButtons.length > 0 && isButtonVisible) hideFloatingButton() } init_userscript_storage() init_state() async function initializeUserscriptData() { const data = await loadDataFromLocalStorageAsync().catch(err => { console.warn( '[Userscript] loadDataFromLocalStorageAsync failed, falling back to sync loader', err ) return loadDataFromLocalStorage() }) userscriptState.emojiGroups = data.emojiGroups || [] userscriptState.settings = data.settings || userscriptState.settings } function shouldInjectEmoji() { if ( document.querySelectorAll( 'meta[name*="discourse"], meta[content*="discourse"], meta[property*="discourse"]' ).length > 0 ) { console.log('[Emoji Extension Userscript] Discourse detected via meta tags') return true } const generatorMeta = document.querySelector('meta[name="generator"]') if (generatorMeta) { const content = generatorMeta.getAttribute('content')?.toLowerCase() || '' if ( content.includes('discourse') || content.includes('flarum') || content.includes('phpbb') ) { console.log('[Emoji Extension Userscript] Forum platform detected via generator meta') return true } } const hostname = window.location.hostname.toLowerCase() if ( ['linux.do', 'meta.discourse.org', 'pixiv.net'].some(domain => hostname.includes(domain)) ) { console.log('[Emoji Extension Userscript] Allowed domain detected:', hostname) return true } if ( document.querySelectorAll( 'textarea.d-editor-input, .ProseMirror.d-editor-input, .composer-input, .reply-area textarea' ).length > 0 ) { console.log('[Emoji Extension Userscript] Discussion editor detected') return true } console.log('[Emoji Extension Userscript] No compatible platform detected') return false } async function initializeEmojiFeature(maxAttempts = 10, delay = 1e3) { console.log('[Emoji Extension Userscript] Initializing...') logPlatformInfo() await initializeUserscriptData() initOneClickAdd() let attempts = 0 function attemptToolbarInjection() { attempts++ const result = attemptInjection() if (result.injectedCount > 0 || result.totalToolbars > 0) { console.log( `[Emoji Extension Userscript] Injection successful: ${result.injectedCount} buttons injected into ${result.totalToolbars} toolbars` ) return } if (attempts < maxAttempts) { console.log( `[Emoji Extension Userscript] Toolbar not found, attempt ${attempts}/${maxAttempts}. Retrying in ${delay / 1e3}s.` ) setTimeout(attemptToolbarInjection, delay) } else { console.error( '[Emoji Extension Userscript] Failed to find toolbar after multiple attempts.' ) console.log('[Emoji Extension Userscript] Showing floating button as fallback') showFloatingButton() } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', attemptToolbarInjection) else attemptToolbarInjection() startPeriodicInjection() setInterval(() => { checkAndShowFloatingButton() }, 5e3) } if (shouldInjectEmoji()) { console.log('[Emoji Extension Userscript] Initializing emoji feature') initializeEmojiFeature() } else console.log('[Emoji Extension Userscript] Skipping injection - incompatible platform') })() })()
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址