// ==UserScript==
// @name Linux do 表情扩展 (Emoji Extension) lite
// @namespace https://github.com/stevessr/bug-v3
// @version 1.0.3
// @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 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;
`,
textContent: 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;
`,
textContent: 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
function injectStyles() {
if (document.getElementById('emoji-extension-floating-button-styles')) return
injectGlobalThemeStyles()
const style = createEl('style', {
id: 'emoji-extension-floating-button-styles',
textContent: getFloatingButtonStyles()
})
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 || result.totalToolbars > 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')
})()
})()