// ==UserScript==
// @name Linux do 表情扩展 (Emoji Extension) lite
// @namespace https://github.com/stevessr/bug-v3
// @version 1.1.1
// @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/*
// @exclude https://linux.do/a/*
// @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,
enableCalloutSuggestions: 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,
enableCalloutSuggestions: 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,
enableCalloutSuggestions: 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,
enableCalloutSuggestions: 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
if (opts.id) el.id = opts.id
}
return el
}
const init_createEl = __esmMin(() => {})
init_createEl()
init_state()
init_userscript_storage()
function createE(tag, opts) {
const el = document.createElement(tag)
if (opts) {
if (opts.wi) el.style.width = opts.wi
if (opts.he) el.style.height = opts.he
if (opts.class) el.className = opts.class
if (opts.text) el.textContent = opts.text
if (opts.ph && 'placeholder' in el) el.placeholder = opts.ph
if (opts.type && 'type' in el) el.type = opts.type
if (opts.val !== void 0 && 'value' in el) el.value = opts.val
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.in) el.innerHTML = opts.in
if (opts.ti) el.title = opts.ti
if (opts.alt && 'alt' in el) el.alt = opts.alt
if (opts.id) el.id = opts.id
if (opts.accept && 'accept' in el) el.accept = opts.accept
if (opts.multiple !== void 0 && 'multiple' in el) el.multiple = opts.multiple
if (opts.role) el.setAttribute('role', opts.role)
if (opts.tabIndex !== void 0) el.tabIndex = Number(opts.tabIndex)
if (opts.ld && 'loading' in el) el.loading = opts.ld
if (opts.on)
for (const [evt, handler] of Object.entries(opts.on)) el.addEventListener(evt, handler)
}
return el
}
async function postTimings(topicId, timings) {
function readCsrfToken() {
try {
const meta = document.querySelector('meta[name="csrf-token"]')
if (meta && meta.content) return meta.content
const input = document.querySelector('input[name="authenticity_token"]')
if (input && input.value) return input.value
const match = document.cookie.match(/csrf_token=([^;]+)/)
if (match) return decodeURIComponent(match[1])
} catch (e) {
console.warn('[timingsBinder] failed to read csrf token', e)
}
return null
}
const csrf = readCsrfToken() || ''
const map = {}
if (Array.isArray(timings)) for (let i = 0; i < timings.length; i++) map[i] = timings[i]
else
for (const k of Object.keys(timings)) {
const key = Number(k)
if (!Number.isNaN(key)) map[key] = timings[key]
}
const params = new URLSearchParams()
let maxTime = 0
for (const idxStr of Object.keys(map)) {
const idx = Number(idxStr)
const val = String(map[idx])
params.append(`timings[${idx}]`, val)
const num = Number(val)
if (!Number.isNaN(num) && num > maxTime) maxTime = num
}
params.append('topic_time', String(maxTime))
params.append('topic_id', String(topicId))
const url = 'https://linux.do/topics/timings'
const headers = {
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
'x-requested-with': 'XMLHttpRequest'
}
if (csrf) headers['x-csrf-token'] = csrf
const MAX_RETRIES = 5
const BASE_DELAY = 500
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const resp = await fetch(url, {
method: 'POST',
body: params.toString(),
credentials: 'same-origin',
headers
})
if (resp.status !== 429) return resp
if (attempt === MAX_RETRIES) return resp
const retryAfter = resp.headers.get('Retry-After')
let waitMs = 0
if (retryAfter) {
const asInt = parseInt(retryAfter, 10)
if (!Number.isNaN(asInt)) waitMs = asInt * 1e3
else {
const date = Date.parse(retryAfter)
if (!Number.isNaN(date)) waitMs = Math.max(0, date - Date.now())
}
}
if (!waitMs)
waitMs = BASE_DELAY * Math.pow(2, attempt) + Math.floor(Math.random() * BASE_DELAY)
await new Promise(resolve => setTimeout(resolve, waitMs))
}
throw new Error('postTimings: unexpected execution path')
}
function notify(message, type = 'info', timeout = 4e3) {
try {
let container = document.getElementById('emoji-ext-toast-container')
if (!container) {
container = createE('div', {
id: 'emoji-ext-toast-container',
style: `
position: fixed;
right: 12px;
bottom: 12px;
z-index: 2147483647;
display: flex;
flex-direction: column;
gap: 8px;
`
})
document.body.appendChild(container)
}
const el = createE('div', {
text: message,
style: `
padding: 8px 12px;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.12);
color: #ffffff;
font-size: 13px;
max-width: 320px;
word-break: break-word;
opacity: 0;
transform: translateY(20px);
`
})
if (type === 'success') el.style.background = '#16a34a'
else if (type === 'error') el.style.background = '#dc2626'
else el.style.background = '#0369a1'
container.appendChild(el)
const id = setTimeout(() => {
el.remove()
clearTimeout(id)
}, timeout)
return () => {
el.remove()
clearTimeout(id)
}
} catch (e) {
try {
alert(message)
} catch (_e) {}
return () => {}
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function fetchPostsForTopic(topicId) {
const url = `/t/${topicId}/posts.json`
const resp = await fetch(url, { credentials: 'same-origin' })
if (!resp.ok) throw new Error(`failed to fetch posts.json: ${resp.status}`)
const data = await resp.json()
let posts = []
let totalCount = 0
if (data && data.post_stream && Array.isArray(data.post_stream.posts)) {
posts = data.post_stream.posts
if (posts.length > 0 && typeof posts[0].posts_count === 'number')
totalCount = posts[0].posts_count
}
if ((!posts || posts.length === 0) && data && Array.isArray(data.posts)) posts = data.posts
if (!totalCount) {
if (data && typeof data.highest_post_number === 'number')
totalCount = data.highest_post_number
else if (data && typeof data.posts_count === 'number') totalCount = data.posts_count
else if (posts && posts.length > 0) totalCount = posts.length
}
return {
posts,
totalCount
}
}
async function autoReadAll(topicId, startFrom = 1) {
try {
let tid = topicId || 0
if (!tid) {
const m1 = window.location.pathname.match(/t\/topic\/(\d+)/)
const m2 = window.location.pathname.match(/t\/(\d+)/)
if (m1 && m1[1]) tid = Number(m1[1])
else if (m2 && m2[1]) tid = Number(m2[1])
else {
const el = document.querySelector('[data-topic-id]')
if (el) tid = Number(el.getAttribute('data-topic-id')) || 0
}
}
if (!tid) {
notify('无法推断 topic_id,自动阅读取消', 'error')
return
}
notify(`开始自动阅读话题 ${tid} 的所有帖子...`, 'info')
const { posts, totalCount } = await fetchPostsForTopic(tid)
if ((!posts || posts.length === 0) && !totalCount) {
notify('未获取到任何帖子或总数信息', 'error')
return
}
const total = totalCount || posts.length
const postNumbers = []
if (startFrom > total) {
notify(`起始帖子号 ${startFrom} 超过总帖子数 ${total},已跳过`, 'info')
return
}
for (let n = startFrom; n <= total; n++) postNumbers.push(n)
const BATCH_SIZE = 10
for (let i = 0; i < postNumbers.length; i += BATCH_SIZE) {
const batch = postNumbers.slice(i, i + BATCH_SIZE)
const timings = {}
for (const pn of batch) timings[pn] = Math.random() * 1e4
try {
await postTimings(tid, timings)
notify(`已标记 ${Object.keys(timings).length} 个帖子为已读(发送)`, 'success')
} catch (e) {
notify('发送阅读标记失败: ' + (e && e.message ? e.message : String(e)), 'error')
}
const delay = 500 + Math.floor(Math.random() * 1e3)
await sleep(delay)
}
notify('自动阅读完成', 'success')
} catch (e) {
notify('自动阅读异常: ' + (e && e.message ? e.message : String(e)), 'error')
}
}
async function autoReadAllv2(topicId) {
let tid = topicId || 0
if (!tid) {
const m1 = window.location.pathname.match(/t\/topic\/(\d+)/)
const m2 = window.location.pathname.match(/t\/(\d+)/)
if (m1 && m1[1]) tid = Number(m1[1])
else if (m2 && m2[1]) tid = Number(m2[1])
else {
const anchors = Array.from(document.querySelectorAll('a[href]'))
const seen$1 = /* @__PURE__ */ new Set()
for (const a of anchors) {
const m = (a.getAttribute('href') || '').match(/^\/t\/topic\/(\d+)(?:\/(\d+))?$/)
if (!m) continue
const id = Number(m[1])
const readPart = m[2] ? Number(m[2]) : void 0
const start = readPart && !Number.isNaN(readPart) ? readPart : 2
if (!id || seen$1.has(id)) continue
seen$1.add(id)
await autoReadAll(id, start)
await sleep(200)
}
}
}
}
window.autoReadAllReplies = autoReadAll
window.autoReadAllRepliesV2 = autoReadAllv2
function insertIntoEditor$1(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$1(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 = createE('div', {
style: `
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 = createE('div', {
style: `
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;
`,
text: '图片上传队列'
})
const closeButton = createE('button', {
text: '✕',
style: `
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 = createE('div', {
class: 'upload-queue-content',
style: `
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 = createE('div', {
style: `
text-align: center;
color: #6b7280;
font-size: 14px;
padding: 20px;
`,
text: '暂无上传任务'
})
content.appendChild(emptyState)
return
}
allItems.forEach(item => {
const itemEl = createE('div', {
style: `
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 = createE('div', {
style: `
flex: 1;
min-width: 0;
`
})
const fileName = createE('div', {
style: `
font-size: 13px;
font-weight: 500;
color: #374151;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
`,
text: item.file.name
})
const status = createE('div', {
style: `
font-size: 12px;
color: #6b7280;
margin-top: 2px;
`
})
status.textContent = this.getStatusText(item)
leftSide.appendChild(fileName)
leftSide.appendChild(status)
const rightSide = createE('div', {
style: `
display: flex;
align-items: center;
gap: 8px;
`
})
if (item.status === 'failed' && item.retryCount < this.maxRetries) {
const retryButton = createE('button', {
text: '🔄',
style: `
background: none;
border: none;
cursor: pointer;
font-size: 14px;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s;
`,
ti: '重试上传'
})
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 = createE('div', {
style: 'font-size: 16px;',
text: 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()
;[
'note',
'abstract',
'summary',
'tldr',
'info',
'todo',
'tip',
'hint',
'success',
'check',
'done',
'question',
'help',
'faq',
'warning',
'caution',
'attention',
'failure',
'fail',
'missing',
'danger',
'error',
'bug',
'example',
'quote',
'cite'
].sort()
const ICONS$2 = {
info: {
icon: 'ℹ️',
color: 'rgba(2, 122, 255, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-lightbulb svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-lightbulb"></use></svg>'
},
tip: {
icon: '💡',
color: 'rgba(0, 191, 188, 0.06)',
svg: '<svg class="fa d-icon d-icon-fire-flame-curved svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#fire-flame-curved"></use></svg>'
},
faq: {
icon: '❓',
color: 'rgba(236, 117, 0, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-circle-question svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-question"></use></svg>'
},
question: {
icon: '🤔',
color: 'rgba(236, 117, 0, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-circle-question svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-question"></use></svg>'
},
note: {
icon: '📝',
color: 'rgba(8, 109, 221, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-pen-to-square svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-pen-to-square"></use></svg>'
},
abstract: {
icon: '📋',
color: 'rgba(0, 191, 188, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-clipboard svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-clipboard"></use></svg>'
},
todo: {
icon: '☑️',
color: 'rgba(2, 122, 255, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-circle-check svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-check"></use></svg>'
},
success: {
icon: '🎉',
color: 'rgba(68, 207, 110, 0.06)',
svg: '<svg class="fa d-icon d-icon-check svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#check"></use></svg>'
},
warning: {
icon: '⚠️',
color: 'rgba(236, 117, 0, 0.06)',
svg: '<svg class="fa d-icon d-icon-triangle-exclamation svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#triangle-exclamation"></use></svg>'
},
failure: {
icon: '❌',
color: 'rgba(233, 49, 71, 0.06)',
svg: '<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>'
},
danger: {
icon: '☠️',
color: 'rgba(233, 49, 71, 0.06)',
svg: '<svg class="fa d-icon d-icon-bolt svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#bolt"></use></svg>'
},
bug: {
icon: '🐛',
color: 'rgba(233, 49, 71, 0.06)',
svg: '<svg class="fa d-icon d-icon-bug svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#bug"></use></svg>'
},
example: {
icon: '🔎',
color: 'rgba(120, 82, 238, 0.06)',
svg: '<svg class="fa d-icon d-icon-list svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#list"></use></svg>'
},
quote: {
icon: '💬',
color: 'rgba(158, 158, 158, 0.06)',
svg: '<svg class="fa d-icon d-icon-quote-left svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#quote-left"></use></svg>'
}
}
ICONS$2.summary = ICONS$2.tldr = ICONS$2.abstract
ICONS$2.hint = ICONS$2.tip
ICONS$2.check = ICONS$2.done = ICONS$2.success
ICONS$2.help = ICONS$2.faq
ICONS$2.caution = ICONS$2.attention = ICONS$2.warning
ICONS$2.fail = ICONS$2.missing = ICONS$2.failure
ICONS$2.error = ICONS$2.danger
ICONS$2.cite = ICONS$2.quote
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)
})
}
const calloutKeywords = [
'note',
'abstract',
'summary',
'tldr',
'info',
'todo',
'tip',
'hint',
'success',
'check',
'done',
'question',
'help',
'faq',
'warning',
'caution',
'attention',
'failure',
'fail',
'missing',
'danger',
'error',
'bug',
'example',
'quote',
'cite'
].sort()
const ICONS$1 = {
info: {
icon: 'ℹ️',
color: 'rgba(2, 122, 255, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-lightbulb svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-lightbulb"></use></svg>'
},
tip: {
icon: '💡',
color: 'rgba(0, 191, 188, 0.06)',
svg: '<svg class="fa d-icon d-icon-fire-flame-curved svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#fire-flame-curved"></use></svg>'
},
faq: {
icon: '❓',
color: 'rgba(236, 117, 0, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-circle-question svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-question"></use></svg>'
},
question: {
icon: '🤔',
color: 'rgba(236, 117, 0, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-circle-question svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-question"></use></svg>'
},
note: {
icon: '📝',
color: 'rgba(8, 109, 221, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-pen-to-square svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-pen-to-square"></use></svg>'
},
abstract: {
icon: '📋',
color: 'rgba(0, 191, 188, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-clipboard svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-clipboard"></use></svg>'
},
todo: {
icon: '☑️',
color: 'rgba(2, 122, 255, 0.06)',
svg: '<svg class="fa d-icon d-icon-far-circle-check svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-check"></use></svg>'
},
success: {
icon: '🎉',
color: 'rgba(68, 207, 110, 0.06)',
svg: '<svg class="fa d-icon d-icon-check svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#check"></use></svg>'
},
warning: {
icon: '⚠️',
color: 'rgba(236, 117, 0, 0.06)',
svg: '<svg class="fa d-icon d-icon-triangle-exclamation svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#triangle-exclamation"></use></svg>'
},
failure: {
icon: '❌',
color: 'rgba(233, 49, 71, 0.06)',
svg: '<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>'
},
danger: {
icon: '☠️',
color: 'rgba(233, 49, 71, 0.06)',
svg: '<svg class="fa d-icon d-icon-bolt svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#bolt"></use></svg>'
},
bug: {
icon: '🐛',
color: 'rgba(233, 49, 71, 0.06)',
svg: '<svg class="fa d-icon d-icon-bug svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#bug"></use></svg>'
},
example: {
icon: '🔎',
color: 'rgba(120, 82, 238, 0.06)',
svg: '<svg class="fa d-icon d-icon-list svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#list"></use></svg>'
},
quote: {
icon: '💬',
color: 'rgba(158, 158, 158, 0.06)',
svg: '<svg class="fa d-icon d-icon-quote-left svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#quote-left"></use></svg>'
}
}
const DEFAULT_ICON = {
icon: '📝',
color: 'var(--secondary-low)',
svg: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="currentColor"><path d="M490.3 40.4C512.2 62.27 512.2 97.73 490.3 119.6L460.3 149.7 362.3 51.72 392.4 21.66C414.3-.2135 449.7-.2135 471.6 21.66L490.3 40.4zM172.4 241.7L339.7 74.34 437.7 172.3 270.3 339.6C264.2 345.8 256.7 350.4 248.4 352.1L159.6 372.9C152.1 374.7 144.3 373.1 138.6 367.4C132.9 361.7 131.3 353.9 133.1 346.4L153.9 257.6C155.6 249.3 160.2 241.8 166.4 235.7L172.4 241.7zM96 64C42.98 64 0 106.1 0 160V416C0 469 42.98 512 96 512H352C405 512 448 469 448 416V320H400V416C400 442.5 378.5 464 352 464H96C69.54 464 48 442.5 48 416V160C48 133.5 69.54 112 96 112H192V64H96z"/></svg>'
}
ICONS$1.summary = ICONS$1.tldr = ICONS$1.abstract
ICONS$1.hint = ICONS$1.tip
ICONS$1.check = ICONS$1.done = ICONS$1.success
ICONS$1.help = ICONS$1.faq
ICONS$1.caution = ICONS$1.attention = ICONS$1.warning
ICONS$1.fail = ICONS$1.missing = ICONS$1.failure
ICONS$1.error = ICONS$1.danger
ICONS$1.cite = ICONS$1.quote
let suggestionBox = null
let activeSuggestionIndex = 0
function createSuggestionBox() {
if (suggestionBox) return
suggestionBox = document.createElement('div')
suggestionBox.id = 'userscript-callout-suggestion-box'
document.body.appendChild(suggestionBox)
injectStyles$1()
}
function injectStyles$1() {
const id = 'userscript-callout-suggestion-styles'
if (document.getElementById(id)) return
const style = document.createElement('style')
style.id = id
style.textContent = `
#userscript-callout-suggestion-box {
position: absolute;
background-color: var(--secondary);
border: 1px solid #444;
border-radius: 6px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
z-index: 999999;
padding: 5px;
display: none;
font-size: 14px;
max-height: 200px;
overflow-y: auto;
}
.userscript-suggestion-item {
padding: 8px 12px;
cursor: pointer;
color: var(--primary-high);
border-radius: 4px;
font-family: monospace;
display: flex;
align-items: center;
}
.userscript-suggestion-item:hover, .userscript-suggestion-item.active {
background-color: var(--primary-low) !important;
}
`
document.head.appendChild(style)
}
function hideSuggestionBox() {
if (suggestionBox) suggestionBox.style.display = 'none'
}
function updateActiveSuggestion() {
if (!suggestionBox) return
suggestionBox.querySelectorAll('.userscript-suggestion-item').forEach((it, idx) => {
it.classList.toggle('active', idx === activeSuggestionIndex)
if (idx === activeSuggestionIndex) it.scrollIntoView({ block: 'nearest' })
})
}
function applyCompletion(textarea, selectedKeyword) {
const text = textarea.value
const selectionStart = textarea.selectionStart || 0
const textBeforeCursor = text.substring(0, selectionStart)
let triggerIndex = textBeforeCursor.lastIndexOf('[')
if (triggerIndex === -1) triggerIndex = textBeforeCursor.lastIndexOf('[')
if (triggerIndex === -1) triggerIndex = textBeforeCursor.lastIndexOf('【')
if (triggerIndex === -1) return
const newText = `[!${selectedKeyword}]`
const textAfter = text.substring(selectionStart)
textarea.value = textBeforeCursor.substring(0, triggerIndex) + newText + textAfter
textarea.selectionStart = textarea.selectionEnd = triggerIndex + newText.length
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
function getCursorXY(textarea) {
const mirrorId = 'userscript-textarea-mirror-div'
let mirror = document.getElementById(mirrorId)
if (!mirror) {
mirror = document.createElement('div')
mirror.id = mirrorId
document.body.appendChild(mirror)
}
const style = window.getComputedStyle(textarea)
;[
'border',
'boxSizing',
'fontFamily',
'fontSize',
'fontWeight',
'height',
'letterSpacing',
'lineHeight',
'outline',
'paddingBottom',
'paddingLeft',
'paddingRight',
'paddingTop',
'textAlign',
'textIndent',
'whiteSpace'
].forEach(p => {
mirror.style[p] = style.getPropertyValue(p)
})
mirror.style.position = 'absolute'
mirror.style.left = '-9999px'
mirror.style.top = '-9999px'
mirror.style.width = style.width
mirror.textContent = textarea.value.substring(0, textarea.selectionEnd)
const span = document.createElement('span')
span.textContent = '.'
mirror.appendChild(span)
return {
x: span.offsetLeft - textarea.scrollLeft,
y: span.offsetTop - textarea.scrollTop
}
}
function updateSuggestionBox(textarea, matches) {
if (!suggestionBox || matches.length === 0) {
hideSuggestionBox()
return
}
suggestionBox.innerHTML = matches
.map((keyword, index) => {
const iconData = ICONS$1[keyword] || DEFAULT_ICON
const backgroundColor = iconData.color || 'transparent'
const iconColor = iconData.color
? iconData.color.replace('rgba', 'rgb').replace(/, [0-9.]+\)/, ')')
: 'var(--primary-medium)'
const coloredSvg = (iconData.svg || DEFAULT_ICON.svg).replace(
'<svg',
`<svg style="color: ${iconColor};"`
)
return `\n <div class="userscript-suggestion-item" data-index="${index}" data-key="${keyword}" style="background-color:${backgroundColor}">\n ${coloredSvg}\n <span style="margin-left:8px">${keyword}</span>\n </div>`
})
.join('')
suggestionBox.querySelectorAll('.userscript-suggestion-item').forEach(item => {
item.addEventListener('mousedown', e => {
e.preventDefault()
const idx = item.dataset.key
if (!idx) return
applyCompletion(textarea, idx)
hideSuggestionBox()
})
})
const rect = textarea.getBoundingClientRect()
const cursorPos = getCursorXY(textarea)
suggestionBox.style.left = `${rect.left + window.scrollX + cursorPos.x}px`
suggestionBox.style.top = `${rect.top + window.scrollY + cursorPos.y + 20}px`
suggestionBox.style.display = 'block'
activeSuggestionIndex = 0
updateActiveSuggestion()
}
function handleInput(event) {
const target = event.target
if (!target || !(target instanceof HTMLTextAreaElement)) return
const textarea = target
const text = textarea.value
const selectionStart = textarea.selectionStart || 0
const match = text.substring(0, selectionStart).match(/(?:\[|[|【])(?:!|!)?([a-z]*)$/i)
if (match) {
const keyword = match[1].toLowerCase()
const filtered = calloutKeywords.filter(k => k.startsWith(keyword))
if (filtered.length > 0) updateSuggestionBox(textarea, filtered)
else hideSuggestionBox()
} else hideSuggestionBox()
}
function handleKeydown(event) {
if (!suggestionBox || suggestionBox.style.display === 'none') return
const items = suggestionBox.querySelectorAll('.userscript-suggestion-item')
if (items.length === 0) return
if (['ArrowDown', 'ArrowUp', 'Tab', 'Enter', 'Escape'].includes(event.key)) {
event.preventDefault()
event.stopPropagation()
}
switch (event.key) {
case 'ArrowDown':
activeSuggestionIndex = (activeSuggestionIndex + 1) % items.length
updateActiveSuggestion()
break
case 'ArrowUp':
activeSuggestionIndex = (activeSuggestionIndex - 1 + items.length) % items.length
updateActiveSuggestion()
break
case 'Tab':
case 'Enter': {
const selectedKey = items[activeSuggestionIndex]?.dataset.key
if (selectedKey) {
const focused = document.activeElement
if (focused && focused instanceof HTMLTextAreaElement)
applyCompletion(focused, selectedKey)
}
hideSuggestionBox()
break
}
case 'Escape':
hideSuggestionBox()
break
}
}
function initCalloutSuggestionsUserscript() {
try {
createSuggestionBox()
document.addEventListener('input', handleInput, true)
document.addEventListener('keydown', handleKeydown, true)
document.addEventListener('click', e => {
if (e.target?.tagName !== 'TEXTAREA' && !suggestionBox?.contains(e.target))
hideSuggestionBox()
})
} catch (e) {
console.error('initCalloutSuggestionsUserscript failed', e)
}
}
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
document.head.appendChild(
createEl('style', {
id: 'emoji-extension-theme-globals',
text: `
/* 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);
}
}
`
})
)
}
let themeStylesInjected
const init_themeSupport = __esmMin(() => {
init_createEl()
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;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
}
/* Main modal panel */
.emoji-manager-panel {
border-radius: 8px;
width: 90%;
height: 95%;
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: var(--primary-very-low)
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: var(--primary-low);
}
.emoji-manager-addgroup-row {
display: flex;
gap: 8px;
padding: 12px;
border-bottom: 1px solid #e9ecef;
}
.emoji-manager-groups-list {
background: var(--primary-very-low);
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: var(--primary);
}
.emoji-manager-groups-list > div:focus {
outline: none;
box-shadow: inset 0 0 0 2px #007bff;
background: var(--primary);
}
/* Right panel - emoji display and editing */
.emoji-manager-right {
background: var(--primary-low);
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(25%, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.emoji-manager-card {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
padding: 12px;
background: var(--primary-medium);
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 {
max-width: 90%;
max-height: 100%; /* allow tall images but cap at viewport height */
object-fit: contain;
border-radius: 6px;
background: white;
}
.emoji-manager-card-name {
font-size: 12px;
color: var(--primary);
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: var(--primary-very-low)
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: var(--primary-very-low)
border-top: 1px solid #e9ecef;
}
/* Editor panel - popup modal */
.emoji-manager-editor-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var( --primary-medium );
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: var(--primary-very-low)
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;
max-width: 60%;
max-height: 60%;
border: 1px solid rgba(0,0,0,0.1);
object-fit: contain;
background: var(--primary);
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: var(--primary);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-high);
}
.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'
})
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:var(--primary-200);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%;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
`
})
const content = createEl('div', {
style: `
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);backdrop-filter: blur(10px);">表情分组编辑器</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>` +
(group.icon?.startsWith('https://')
? `<img class="group-icon-editor" src="${group.icon}" alt="图标" style="
width: 40px;
height: 40px;
object-fit: cover;
border: 1px dashed var(--emoji-modal-border);
border-radius: 4px;
cursor: pointer;
user-select: none;
" data-group-id="${group.id}" title="点击编辑图标">`
: `
<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 = createEl('style', {
text: `
.group-item:hover {
border-color: var(--emoji-modal-primary-bg) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.group-icon-editor:hover {
background: var(--emoji-modal-primary-bg) !important;
color: white;
}
.move-up:hover, .move-down:hover {
background: var(--emoji-modal-primary-bg) !important;
color: white;
}
.move-up:disabled, .move-down:disabled {
opacity: 0.3;
cursor: not-allowed !important;
}
`
})
document.head.appendChild(style)
content.querySelector('#closeModal')?.addEventListener('click', () => {
modal.remove()
style.remove()
})
modal.addEventListener('click', e => {
if (e.target === modal) {
modal.remove()
style.remove()
}
})
content.querySelectorAll('.group-name-editor').forEach(input => {
input.addEventListener('change', e => {
const target = e.target
const groupId = target.getAttribute('data-group-id')
const newName = target.value.trim()
if (groupId && newName) {
const group = userscriptState.emojiGroups.find(g => g.id === groupId)
if (group) {
group.name = newName
showTemporaryMessage$1(`分组 "${newName}" 名称已更新`)
}
}
})
})
content.querySelectorAll('.group-icon-editor').forEach(iconEl => {
iconEl.addEventListener('click', e => {
const target = e.target
const groupId = target.getAttribute('data-group-id')
if (groupId) {
const newIcon = prompt(
'请输入新的图标字符 (emoji 或单个字符):',
target.textContent || '📁'
)
if (newIcon && newIcon.trim()) {
const group = userscriptState.emojiGroups.find(g => g.id === groupId)
if (group) {
group.icon = newIcon.trim()
target.textContent = newIcon.trim()
showTemporaryMessage$1(`分组图标已更新为: ${newIcon.trim()}`)
}
}
}
})
})
content.querySelectorAll('.move-up').forEach(btn => {
btn.addEventListener('click', e => {
const index = parseInt(e.target.getAttribute('data-index') || '0')
if (index > 0) {
const temp = userscriptState.emojiGroups[index]
userscriptState.emojiGroups[index] = userscriptState.emojiGroups[index - 1]
userscriptState.emojiGroups[index - 1] = temp
modal.remove()
style.remove()
showTemporaryMessage$1('分组顺序已调整')
setTimeout(() => showGroupEditorModal(), 300)
}
})
})
content.querySelectorAll('.move-down').forEach(btn => {
btn.addEventListener('click', e => {
const index = parseInt(e.target.getAttribute('data-index') || '0')
if (index < userscriptState.emojiGroups.length - 1) {
const temp = userscriptState.emojiGroups[index]
userscriptState.emojiGroups[index] = userscriptState.emojiGroups[index + 1]
userscriptState.emojiGroups[index + 1] = temp
modal.remove()
style.remove()
showTemporaryMessage$1('分组顺序已调整')
setTimeout(() => showGroupEditorModal(), 300)
}
})
})
content.querySelector('#addNewGroup')?.addEventListener('click', () => {
const groupName = prompt('请输入新分组的名称:')
if (groupName && groupName.trim()) {
const newGroup = {
id: 'custom_' + Date.now(),
name: groupName.trim(),
icon: '📁',
order: userscriptState.emojiGroups.length,
emojis: []
}
userscriptState.emojiGroups.push(newGroup)
modal.remove()
style.remove()
showTemporaryMessage$1(`新分组 "${groupName.trim()}" 已创建`)
setTimeout(() => showGroupEditorModal(), 300)
}
})
content.querySelector('#saveAllChanges')?.addEventListener('click', () => {
saveDataToLocalStorage({ emojiGroups: userscriptState.emojiGroups })
showTemporaryMessage$1('所有更改已保存到本地存储')
})
}
function showTemporaryMessage$1(message) {
const messageEl = createEl('div', {
style: `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--emoji-modal-primary-bg);
color: white;
padding: 12px 24px;
border-radius: 6px;
z-index: 9999999;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: fadeInOut 2s ease-in-out;
`,
text: message
})
if (!document.querySelector('#tempMessageStyles'))
document.head.appendChild(
createEl('style', {
id: 'tempMessageStyles',
text: `
@keyframes fadeInOut {
0%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
20%, 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
`
})
)
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%;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
`
})
const content = createEl('div', {
style: `
background: var(--emoji-modal-bg);
color: var(--emoji-modal-text);
border-radius: 8px;
padding: 24px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
position: relative;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
`
})
const popularEmojis = getPopularEmojis(50)
content.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h2 style="margin: 0; color: var(--emoji-modal-text);">常用表情 (${popularEmojis.length})</h2>
<div style="display: flex; gap: 8px; align-items: center;">
<button id="clearStats" style="padding: 6px 12px; background: #ff4444; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px;">清空统计</button>
<button id="closeModal" style="background: none; border: none; font-size: 24px; cursor: pointer; color: #999;">×</button>
</div>
</div>
<div style="margin-bottom: 16px; padding: 12px; background: var(--emoji-modal-button-bg); border-radius: 6px; border: 1px solid var(--emoji-modal-border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<span style="font-weight: 500; color: var(--emoji-modal-label);">表情按使用次数排序</span>
<span style="font-size: 12px; color: var(--emoji-modal-text); opacity: 0.7;">点击表情直接使用</span>
</div>
<div style="font-size: 12px; color: var(--emoji-modal-text); opacity: 0.6;">
总使用次数: ${popularEmojis.reduce((sum, emoji) => sum + emoji.count, 0)}
</div>
</div>
<div id="popularEmojiGrid" style="
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 8px;
max-height: 400px;
overflow-y: auto;
">
${
popularEmojis.length === 0
? '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: var(--emoji-modal-text); opacity: 0.7;">还没有使用过表情<br><small>开始使用表情后,这里会显示常用的表情</small></div>'
: popularEmojis
.map(
emoji => `
<div class="popular-emoji-item" data-name="${emoji.name}" data-url="${emoji.url}" style="
display: flex;
flex-direction: column;
align-items: center;
padding: 8px;
border: 1px solid var(--emoji-modal-border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
background: var(--emoji-modal-button-bg);
">
<img src="${emoji.url}" alt="${emoji.name}" style="
width: 40px;
height: 40px;
object-fit: contain;
margin-bottom: 4px;
">
<div style="
font-size: 11px;
font-weight: 500;
color: var(--emoji-modal-text);
text-align: center;
word-break: break-all;
line-height: 1.2;
margin-bottom: 2px;
">${emoji.name}</div>
<div style="
font-size: 10px;
color: var(--emoji-modal-text);
opacity: 0.6;
text-align: center;
">使用${emoji.count}次</div>
</div>
`
)
.join('')
}
</div>
${
popularEmojis.length > 0
? `
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--emoji-modal-border); font-size: 12px; color: var(--emoji-modal-text); opacity: 0.6; text-align: center;">
统计数据保存在本地,清空统计将重置所有使用记录
</div>
`
: ''
}
`
modal.appendChild(content)
document.body.appendChild(modal)
const style = document.createElement('style')
style.textContent = `
.popular-emoji-item:hover {
transform: translateY(-2px);
border-color: var(--emoji-modal-primary-bg) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
`
document.head.appendChild(style)
content.querySelector('#closeModal')?.addEventListener('click', () => {
modal.remove()
style.remove()
})
content.querySelector('#clearStats')?.addEventListener('click', () => {
if (confirm('确定要清空所有表情使用统计吗?此操作不可撤销。')) {
clearEmojiUsageStats()
modal.remove()
style.remove()
showTemporaryMessage('表情使用统计已清空')
setTimeout(() => showPopularEmojisModal(), 300)
}
})
content.querySelectorAll('.popular-emoji-item').forEach(item => {
item.addEventListener('click', () => {
const name = item.getAttribute('data-name')
const url = item.getAttribute('data-url')
if (name && url) {
trackEmojiUsage(name, url)
useEmojiFromPopular(name, url)
modal.remove()
style.remove()
showTemporaryMessage(`已使用表情: ${name}`)
}
})
})
modal.addEventListener('click', e => {
if (e.target === modal) {
modal.remove()
style.remove()
}
})
}
function useEmojiFromPopular(name, url) {
const activeElement = document.activeElement
if (
activeElement &&
(activeElement.tagName === 'TEXTAREA' || activeElement.tagName === 'INPUT')
) {
const textArea = activeElement
const format = userscriptState.settings.outputFormat
let emojiText = ''
if (format === 'markdown') emojiText = ``
else
emojiText = `<img src="${url}" alt="${name}" style="width: ${userscriptState.settings.imageScale}px; height: ${userscriptState.settings.imageScale}px;">`
const start = textArea.selectionStart || 0
const end = textArea.selectionEnd || 0
const currentValue = textArea.value
textArea.value = currentValue.slice(0, start) + emojiText + currentValue.slice(end)
const newPosition = start + emojiText.length
textArea.setSelectionRange(newPosition, newPosition)
textArea.dispatchEvent(new Event('input', { bubbles: true }))
textArea.focus()
} else {
const textAreas = document.querySelectorAll(
'textarea, input[type="text"], [contenteditable="true"]'
)
const lastTextArea = Array.from(textAreas).pop()
if (lastTextArea) {
lastTextArea.focus()
if (lastTextArea.tagName === 'TEXTAREA' || lastTextArea.tagName === 'INPUT') {
const format = userscriptState.settings.outputFormat
let emojiText = ''
if (format === 'markdown') emojiText = ``
else
emojiText = `<img src="${url}" alt="${name}" style="width: ${userscriptState.settings.imageScale}px; height: ${userscriptState.settings.imageScale}px;">`
const textarea = lastTextArea
textarea.value += emojiText
textarea.dispatchEvent(new Event('input', { bubbles: true }))
}
}
}
}
function showTemporaryMessage(message) {
const messageEl = createEl('div', {
style: `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--emoji-modal-primary-bg);
color: white;
padding: 12px 24px;
border-radius: 6px;
z-index: 9999999;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
animation: fadeInOut 2s ease-in-out;
`,
text: message
})
if (!document.querySelector('#tempMessageStyles')) {
const style = document.createElement('style')
style.id = 'tempMessageStyles'
style.textContent = `
@keyframes fadeInOut {
0%, 100% { opacity: 0; transform: translate(-50%, -50%) scale(0.9); }
20%, 80% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
}
`
document.head.appendChild(style)
}
document.body.appendChild(messageEl)
setTimeout(() => {
messageEl.remove()
}, 2e3)
}
const init_popularEmojis = __esmMin(() => {
init_state()
init_userscript_storage()
init_createEl()
init_themeSupport()
})
const settings_exports = /* @__PURE__ */ __export({
showSettingsModal: () => showSettingsModal
})
function showSettingsModal() {
injectGlobalThemeStyles()
const modal = createEl('div', {
style: `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999999;
display: flex;
align-items: center;
justify-content: center;
`
})
const content = createEl('div', {
style: `
backdrop-filter: blur(10px);
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="enableCalloutSuggestions" ${userscriptState.settings.enableCalloutSuggestions ? 'checked' : ''} style="margin-right: 8px;">
在 textarea 中启用 Callout Suggestion(输入 [ 即可触发)
</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,
enableCalloutSuggestions: 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 enableCalloutEl = content.querySelector('#enableCalloutSuggestions')
if (enableCalloutEl)
userscriptState.settings.enableCalloutSuggestions = !!enableCalloutEl.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:transparent;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:var(--primary);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;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:var(--primary);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)
if (modal.parentElement) modal.parentElement.removeChild(modal)
})
img.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
insertEmojiIntoEditor(emoji)
if (modal.parentElement) modal.parentElement.removeChild(modal)
}
})
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()
const QUICK_INSERTS = [
'info',
'tip',
'faq',
'question',
'note',
'abstract',
'todo',
'success',
'warning',
'failure',
'danger',
'bug',
'example',
'quote'
]
const ICONS = {
info: {
icon: 'ℹ️',
color: 'rgba(2, 122, 255, 0.1)',
svg: '<svg class="fa d-icon d-icon-far-lightbulb svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-lightbulb"></use></svg>'
},
tip: {
icon: '💡',
color: 'rgba(0, 191, 188, 0.1);',
svg: '<svg class="fa d-icon d-icon-fire-flame-curved svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#fire-flame-curved"></use></svg>'
},
faq: {
icon: '❓',
color: 'rgba(236, 117, 0, 0.1);',
svg: '<svg class="fa d-icon d-icon-far-circle-question svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-question"></use></svg>'
},
question: {
icon: '🤔',
color: 'rgba(236, 117, 0, 0.1);',
svg: '<svg class="fa d-icon d-icon-far-circle-question svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-question"></use></svg>'
},
note: {
icon: '📝',
color: 'rgba(8, 109, 221, 0.1);',
svg: '<svg class="fa d-icon d-icon-far-pen-to-square svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-pen-to-square"></use></svg>'
},
abstract: {
icon: '📋',
color: 'rgba(0, 191, 188, 0.1);',
svg: '<svg class="fa d-icon d-icon-far-clipboard svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-clipboard"></use></svg>'
},
todo: {
icon: '☑️',
color: 'rgba(2, 122, 255, 0.1);',
svg: '<svg class="fa d-icon d-icon-far-circle-check svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#far-circle-check"></use></svg>'
},
success: {
icon: '🎉',
color: 'rgba(68, 207, 110, 0.1);',
svg: '<svg class="fa d-icon d-icon-check svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#check"></use></svg>'
},
warning: {
icon: '⚠️',
color: 'rgba(236, 117, 0, 0.1);',
svg: '<svg class="fa d-icon d-icon-triangle-exclamation svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#triangle-exclamation"></use></svg>'
},
failure: {
icon: '❌',
color: 'rgba(233, 49, 71, 0.1);',
svg: '<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>'
},
danger: {
icon: '☠️',
color: 'rgba(233, 49, 71, 0.1);',
svg: '<svg class="fa d-icon d-icon-bolt svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#bolt"></use></svg>'
},
bug: {
icon: '🐛',
color: 'rgba(233, 49, 71, 0.1);',
svg: '<svg class="fa d-icon d-icon-bug svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#bug"></use></svg>'
},
example: {
icon: '🔎',
color: 'rgba(120, 82, 238, 0.1);',
svg: '<svg class="fa d-icon d-icon-list svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#list"></use></svg>'
},
quote: {
icon: '💬',
color: 'rgba(158, 158, 158, 0.1);',
svg: '<svg class="fa d-icon d-icon-quote-left svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#quote-left"></use></svg>'
}
}
function insertIntoEditor(text) {
const active = document.activeElement
const isTextarea = el => !!el && el.tagName === 'TEXTAREA'
if (isTextarea(active)) {
const textarea = active
const start = textarea.selectionStart ?? 0
const end = textarea.selectionEnd ?? start
const value = textarea.value
textarea.value = value.slice(0, start) + text + value.slice(end)
const pos = start + text.length
if ('setSelectionRange' in textarea)
try {
textarea.setSelectionRange(pos, pos)
} catch (e) {}
textarea.dispatchEvent(new Event('input', { bubbles: true }))
return
}
if (active && active.isContentEditable) {
const sel = window.getSelection()
if (!sel) return
const range = sel.getRangeAt(0)
range.deleteContents()
const node = document.createTextNode(text)
range.insertNode(node)
range.setStartAfter(node)
range.setEndAfter(node)
sel.removeAllRanges()
sel.addRange(range)
active.dispatchEvent(new Event('input', { bubbles: true }))
return
}
const fallback = document.querySelector('textarea')
if (fallback) {
fallback.focus()
const start = fallback.selectionStart ?? fallback.value.length
const end = fallback.selectionEnd ?? start
const value = fallback.value
fallback.value = value.slice(0, start) + text + value.slice(end)
const pos = start + text.length
if ('setSelectionRange' in fallback)
try {
fallback.setSelectionRange(pos, pos)
} catch (e) {}
fallback.dispatchEvent(new Event('input', { bubbles: true }))
}
}
function createQuickInsertMenu() {
const menu = createEl('div', {
className:
'fk-d-menu toolbar-menu__options-content toolbar-popup-menu-options -animated -expanded'
})
const inner = createEl('div', { className: 'fk-d-menu__inner-content' })
const list = createEl('ul', { className: 'dropdown-menu' })
QUICK_INSERTS.forEach(key => {
const li = createEl('li', { className: 'dropdown-menu__item' })
const btn = createEl('button', {
className: 'btn btn-icon-text',
type: 'button',
title: key.charAt(0).toUpperCase() + key.slice(1),
style: 'background: ' + (ICONS[key]?.color || 'auto')
})
btn.addEventListener('click', () => {
if (menu.parentElement) menu.parentElement.removeChild(menu)
insertIntoEditor(`>[!${key}]+\n`)
})
const emojiSpan = createEl('span', {
className: 'd-button-emoji',
text: ICONS[key]?.icon || '✳️',
style: 'margin-right: 6px;'
})
const labelWrap = createEl('span', { className: 'd-button-label' })
const labelText = createEl('span', {
className: 'd-button-label__text',
text: key.charAt(0).toUpperCase() + key.slice(1)
})
labelWrap.appendChild(labelText)
const svgHtml = ICONS[key]?.svg || ''
if (svgHtml) {
const svgSpan = createEl('span', {
className: 'd-button-label__svg',
innerHTML: svgHtml,
style: 'margin-left: 6px; display: inline-flex; align-items: center;'
})
labelWrap.appendChild(svgSpan)
}
btn.appendChild(emojiSpan)
btn.appendChild(labelWrap)
li.appendChild(btn)
list.appendChild(li)
})
inner.appendChild(list)
menu.appendChild(inner)
return menu
}
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: '⭐'
})
const autoReadButton = createEl('button', {
className: 'btn no-text btn-icon toolbar__button emoji-extension-auto-read',
title: '像插件一样自动阅读话题 (Auto-read topics)',
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', '')
}
autoReadButton.addEventListener('click', async e => {
e.stopPropagation()
try {
const caller = window.callAutoReadRepliesV2 || window.autoReadAllRepliesV2
if (caller && typeof caller === 'function') {
await caller()
console.log('[Userscript] autoRead triggered via toolbar button')
} else console.warn('[Userscript] autoRead function not available on this page')
} catch (err) {
console.error('[Userscript] autoRead failed', err)
}
})
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()
})
if (isChatComposer) popularButton.after(autoReadButton)
else button.after(autoReadButton)
const quickInsertButton = createEl('button', {
className: 'btn no-text btn-icon toolbar__button quick-insert-button',
title: '快捷输入',
type: 'button',
innerHTML: '⎘'
})
if (isChatComposer) {
quickInsertButton.classList.add(
'fk-d-menu__trigger',
'chat-composer-button',
'btn-transparent'
)
quickInsertButton.setAttribute('aria-expanded', 'false')
quickInsertButton.setAttribute('data-trigger', '')
}
quickInsertButton.addEventListener('click', e => {
e.stopPropagation()
const menu = createQuickInsertMenu()
;(document.querySelector('#d-menu-portals') || document.body).appendChild(menu)
const rect = quickInsertButton.getBoundingClientRect()
menu.style.position = 'fixed'
menu.style.zIndex = '10000'
menu.style.top = `${rect.bottom + 5}px`
menu.style.left = `${Math.max(8, Math.min(rect.left + rect.width / 2 - 150, window.innerWidth - 300))}px`
const removeMenu = ev => {
if (!menu.contains(ev.target)) {
if (menu.parentElement) menu.parentElement.removeChild(menu)
document.removeEventListener('click', removeMenu)
}
}
setTimeout(() => document.addEventListener('click', removeMenu), 100)
})
try {
if (isChatComposer) {
const existingEmojiTrigger = toolbar.querySelector(
'.emoji-picker-trigger:not(.emoji-extension-button)'
)
if (existingEmojiTrigger) {
toolbar.insertBefore(button, existingEmojiTrigger)
toolbar.insertBefore(quickInsertButton, existingEmojiTrigger)
toolbar.insertBefore(popularButton, existingEmojiTrigger)
} else {
toolbar.appendChild(button)
toolbar.appendChild(quickInsertButton)
toolbar.appendChild(popularButton)
}
} else {
toolbar.appendChild(button)
toolbar.appendChild(quickInsertButton)
toolbar.appendChild(popularButton)
}
} catch (error) {
console.error('[Emoji Extension Userscript] Failed to inject button:', error)
}
}
function attemptInjection() {
const toolbars = findAllToolbars()
let injectedCount = 0
toolbars.forEach(toolbar => {
if (!toolbar.querySelector('.emoji-extension-button')) {
console.log('[Emoji Extension Userscript] Toolbar found, injecting button.')
injectEmojiButton(toolbar)
injectedCount++
}
})
return {
injectedCount,
totalToolbars: toolbars.length
}
}
function startPeriodicInjection() {
setInterval(() => {
findAllToolbars().forEach(toolbar => {
if (!toolbar.querySelector('.emoji-extension-button')) {
console.log('[Emoji Extension Userscript] New toolbar found, injecting button.')
injectEmojiButton(toolbar)
}
})
}, 3e4)
}
init_createEl()
init_themeSupport()
let floatingButton = null
let isButtonVisible = false
const FLOATING_BUTTON_STYLES = `
.emoji-extension-floating-container {
position: fixed !important;
bottom: 20px !important;
right: 20px !important;
display: flex !important;
flex-direction: column !important;
gap: 10px !important;
z-index: 999999 !important;
}
.emoji-extension-floating-button {
width: 56px !important;
height: 56px !important;
border-radius: 50% !important;
background: transparent;
border: none !important;
box-shadow: 0 4px 12px var(--emoji-button-shadow) !important;
cursor: pointer !important;
font-size: 24px !important;
color: white !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
transition: all 0.2s ease !important;
opacity: 0.95 !important;
}
.emoji-extension-floating-button:hover {
transform: scale(1.05) !important;
}
.emoji-extension-floating-button:active { transform: scale(0.95) !important; }
.emoji-extension-floating-button.secondary {
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%) !important;
}
.emoji-extension-floating-button.hidden {
opacity: 0 !important;
pointer-events: none !important;
transform: translateY(20px) !important;
}
@media (max-width: 768px) {
.emoji-extension-floating-button {
width: 48px !important;
height: 48px !important;
font-size: 20px !important; }
.emoji-extension-floating-container { bottom: 15px !important; right: 15px !important; }
}
`
function injectStyles() {
if (document.getElementById('emoji-extension-floating-button-styles')) return
injectGlobalThemeStyles()
const style = createEl('style', {
attrs: { id: 'emoji-extension-floating-button-styles' },
text: FLOATING_BUTTON_STYLES
})
document.head.appendChild(style)
}
function createManualButton() {
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 {
if (attemptInjection().injectedCount > 0) {
button.innerHTML = '✅'
setTimeout(() => {
button.innerHTML = '🐈⬛'
button.style.transform = 'scale(1)'
}, 1500)
} else {
button.innerHTML = '❌'
setTimeout(() => {
button.innerHTML = '🐈⬛'
button.style.transform = 'scale(1)'
}, 1500)
}
} catch (error) {
button.innerHTML = '⚠️'
setTimeout(() => {
button.innerHTML = '🐈⬛'
button.style.transform = 'scale(1)'
}, 1500)
console.error('[Emoji Extension Userscript] Manual injection error:', error)
}
})
return button
}
function createAutoReadButton() {
const btn = createEl('button', {
className: 'emoji-extension-floating-button secondary',
title: '像插件一样自动阅读话题 (Auto-read topics)',
innerHTML: '📖'
})
btn.addEventListener('click', async e => {
e.stopPropagation()
e.preventDefault()
btn.style.transform = 'scale(0.9)'
btn.innerHTML = '⏳'
try {
const fn = window.callAutoReadRepliesV2 || window.autoReadAllRepliesV2
if (fn && typeof fn === 'function') {
await fn()
btn.innerHTML = '✅'
} else {
btn.innerHTML = '❌'
console.warn('[Emoji Extension] autoRead function not available on window')
}
} catch (err) {
console.error('[Emoji Extension] auto-read failed', err)
btn.innerHTML = '⚠️'
}
setTimeout(() => {
btn.innerHTML = '📖'
btn.style.transform = 'scale(1)'
}, 1500)
})
return btn
}
function createAutoReadMenuItem() {
const li = createEl('li', { className: 'submenu-item emoji-extension-auto-read' })
const a = createEl('a', {
className: 'submenu-link',
attrs: {
href: '#',
title: '像插件一样自动阅读话题 (Auto-read topics)'
},
innerHTML: `
<svg class="fa d-icon d-icon-book svg-icon svg-string" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><use href="#book"></use></svg>
自动阅读
`
})
a.addEventListener('click', async e => {
e.preventDefault()
e.stopPropagation()
try {
const fn = window.callAutoReadRepliesV2 || window.autoReadAllRepliesV2
if (fn && typeof fn === 'function') await fn()
else console.warn('[Emoji Extension] autoRead function not available on window')
} catch (err) {
console.error('[Emoji Extension] auto-read menu invocation failed', err)
}
})
li.appendChild(a)
return li
}
function showFloatingButton() {
if (floatingButton) return
injectStyles()
const manual = createManualButton()
const wrapper = createEl('div', { className: 'emoji-extension-floating-container' })
wrapper.appendChild(manual)
document.body.appendChild(wrapper)
floatingButton = wrapper
isButtonVisible = true
console.log(
'[Emoji Extension Userscript] Floating manual injection button shown (bottom-right)'
)
}
async function injectIntoUserMenu(el) {
const SELECTOR_OTHER_ANCHOR =
'a.menu-item[title="其他服务"], a.menu-item.vdm[title="其他服务"]'
const SELECTOR_OTHER_DROPDOWN = '.d-header-dropdown .d-dropdown-menu'
const SELECTOR_TOP = '.menu-tabs-container .top-tabs'
const SELECTOR_CONTAINER = '.menu-tabs-container'
for (;;) {
const otherAnchor = document.querySelector(SELECTOR_OTHER_ANCHOR)
if (otherAnchor) {
const dropdown = otherAnchor.querySelector(SELECTOR_OTHER_DROPDOWN)
if (dropdown) {
if (el.tagName.toLowerCase() === 'li') dropdown.appendChild(el)
else {
const wrapper = createEl('li', { className: 'submenu-item' })
wrapper.appendChild(el)
dropdown.appendChild(wrapper)
}
isButtonVisible = true
console.log('[Emoji Extension Userscript] Auto-read injected into 其他服务 dropdown')
return
}
}
const top = document.querySelector(SELECTOR_TOP)
if (top) {
top.appendChild(el)
isButtonVisible = true
console.log('[Emoji Extension Userscript] Floating button injected into top-tabs')
return
}
const container = document.querySelector(SELECTOR_CONTAINER)
if (container) {
container.appendChild(el)
isButtonVisible = true
console.log(
'[Emoji Extension Userscript] Floating button injected into menu-tabs-container'
)
return
}
await new Promise(resolve => setTimeout(resolve, 500))
}
}
async function showAutoReadInMenu() {
injectStyles()
const menuItem = createAutoReadMenuItem()
try {
await injectIntoUserMenu(menuItem)
return
} catch (e) {
console.warn(
'[Emoji Extension Userscript] injecting menu item failed, falling back to button',
e
)
}
const btn = createAutoReadButton()
try {
await injectIntoUserMenu(btn)
} catch (e) {
document.body.appendChild(btn)
console.log('[Emoji Extension Userscript] Auto-read button appended to body as fallback')
}
}
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()
try {
if (userscriptState.settings?.enableCalloutSuggestions) {
initCalloutSuggestionsUserscript()
console.log('[Userscript] Callout suggestions enabled')
}
} catch (e) {
console.warn('[Userscript] initCalloutSuggestionsUserscript failed', e)
}
try {
showAutoReadInMenu()
} catch (e) {
console.warn('[Userscript] showAutoReadInMenu failed', e)
}
function exposeAutoReadWrapper() {
try {
const existing = window.autoReadAllRepliesV2
if (existing && typeof existing === 'function') {
window.callAutoReadRepliesV2 = topicId => {
try {
return existing(topicId)
} catch (e) {
console.warn('[Userscript] callAutoReadRepliesV2 invocation failed', e)
}
}
console.log('[Userscript] callAutoReadRepliesV2 is exposed')
return
}
window.callAutoReadRepliesV2 = topicId => {
console.warn('[Userscript] autoReadAllRepliesV2 not available on this page yet')
}
} catch (e) {
console.warn('[Userscript] exposeAutoReadWrapper failed', e)
}
}
exposeAutoReadWrapper()
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')
})()
})()