// ==UserScript==
// @name Discourse 表情选择器 (Emoji Picker) core lite
// @namespace https://github.com/stevessr/bug-v3
// @version 1.2.4
// @description Discourse 论坛表情选择器 - 核心功能 (Emoji picker for Discourse - Core features only)
// @author stevessr
// @match https://linux.do/*
// @match https://meta.discourse.org/*
// @match https://*.discourse.org/*
// @match http://localhost:5173/*
// @exclude https://linux.do/a/*
// @match https://idcflare.com/*
// @grant none
// @license MIT
// @homepageURL https://github.com/stevessr/bug-v3
// @supportURL https://github.com/stevessr/bug-v3/issues
// @run-at document-end
// @downloadURL https://update.greasyfork.icu/scripts/554979/Discourse%20%E8%A1%A8%E6%83%85%E9%80%89%E6%8B%A9%E5%99%A8%20%28Emoji%20Picker%29%20core%20lite.user.js
// @updateURL https://update.greasyfork.icu/scripts/554979/Discourse%20%E8%A1%A8%E6%83%85%E9%80%89%E6%8B%A9%E5%99%A8%20%28Emoji%20Picker%29%20core%20lite.meta.js
// ==/UserScript==
(function() {
'use strict';
(function() {
async function fetchPackagedJSON(url) {
try {
if (typeof fetch === "undefined") return null;
const res = await fetch(url || "/assets/defaultEmojiGroups.json", {
cache: "no-cache",
credentials: "omit"
});
if (!res.ok) return null;
return await res.json();
} catch (err) {
return null;
}
}
async function loadAndFilterDefaultEmojiGroups(url, hostname) {
const packaged = await fetchPackagedJSON(url);
if (!packaged || !Array.isArray(packaged.groups)) return [];
if (!hostname) return packaged.groups;
return packaged.groups.map((group) => {
const filteredEmojis = group.emojis.filter((emoji) => {
try {
const url$1 = emoji.url;
if (!url$1) return false;
const emojiHostname = new URL(url$1).hostname;
return emojiHostname === hostname || emojiHostname.endsWith("." + hostname);
} catch (e) {
return true;
}
});
return {
...group,
emojis: filteredEmojis
};
}).filter((group) => group.emojis.length > 0);
}
var STORAGE_KEY = "emoji_extension_userscript_data";
var SETTINGS_KEY = "emoji_extension_userscript_settings";
var USAGE_STATS_KEY = "emoji_extension_userscript_usage_stats";
const DEFAULT_USER_SETTINGS = {
imageScale: 30,
gridColumns: 4,
outputFormat: "markdown",
forceMobileMode: false,
defaultGroup: "nachoneko",
showSearchBar: true,
enableFloatingPreview: true,
enableCalloutSuggestions: true,
enableBatchParseImages: true
};
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 = { ...DEFAULT_USER_SETTINGS };
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);
return {
emojiGroups: [],
settings: { ...DEFAULT_USER_SETTINGS }
};
}
}
async function loadDataFromLocalStorageAsync(hostname) {
try {
const local = loadDataFromLocalStorage();
if (local.emojiGroups && local.emojiGroups.length > 0) return local;
const remoteUrl = localStorage.getItem("emoji_extension_remote_config_url");
const configUrl = remoteUrl && typeof remoteUrl === "string" && remoteUrl.trim().length > 0 ? remoteUrl : "https://video2gif-pages.pages.dev/assets/defaultEmojiGroups.json";
try {
const groups = await loadAndFilterDefaultEmojiGroups(configUrl, hostname);
if (groups && groups.length > 0) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(groups));
} catch (e) {
console.warn("[Userscript] Failed to persist fetched groups to localStorage", e);
}
return {
emojiGroups: groups.filter((g) => g.id !== "favorites"),
settings: local.settings
};
}
} catch (err) {
console.warn(`[Userscript] Failed to fetch config from ${configUrl}:`, err);
}
return {
emojiGroups: [],
settings: local.settings
};
} catch (error) {
console.error("[Userscript] loadDataFromLocalStorageAsync failed:", error);
return {
emojiGroups: [],
settings: { ...DEFAULT_USER_SETTINGS }
};
}
}
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 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);
}
}
const userscriptState = {
emojiGroups: [],
settings: { ...DEFAULT_USER_SETTINGS },
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;
if (opts.on) for (const [evt, handler] of Object.entries(opts.on)) el.addEventListener(evt, handler);
}
return el;
}
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$1(url);
name = name.trim();
if (name.length === 0) name = "表情";
return {
name,
url
};
}
function extractEmojiDataFromLightboxWrapper(lightboxWrapper) {
const results = [];
const anchor = lightboxWrapper.querySelector("a.lightbox");
const img = lightboxWrapper.querySelector("img");
if (!anchor || !img) return results;
const title = anchor.getAttribute("title") || "";
const originalUrl = anchor.getAttribute("href") || "";
const downloadUrl = anchor.getAttribute("data-download-href") || "";
const imgSrc = img.getAttribute("src") || "";
let name = title || img.getAttribute("alt") || "";
if (!name || name.length < 2) name = extractNameFromUrl$1(originalUrl || downloadUrl || imgSrc);
name = name.replace(/\\.(webp|jpg|jpeg|png|gif)$/i, "").trim() || "表情";
const urlToUse = originalUrl || downloadUrl || imgSrc;
if (urlToUse && urlToUse.startsWith("http")) results.push({
name,
url: urlToUse
});
return results;
}
function extractNameFromUrl$1(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$1(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 = `
添加表情
`;
link.title = "添加到用户表情";
link.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const originalHTML = link.innerHTML;
const originalStyle = link.style.cssText;
try {
addEmojiToUserscript(emojiData);
link.innerHTML = `
已添加
`;
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 = `
失败
`;
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$1(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);
});
initBatchParseButtons();
}
function createBatchParseButton(cookedElement) {
const button = createEl("button", {
className: "emoji-batch-parse-button",
style: `
display: inline-flex;
align-items: center;
gap: 6px;
background: var(--tertiary-low);
color: var(--d-button-default-icon-color);
border-radius: 8px;
padding: 8px 12px;
margin: 10px 0;
font-weight: 600;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
transition: all 0.2s ease;
`
});
button.innerHTML = `
一键解析并添加所有图片
`;
button.title = "解析当前内容中的所有图片并添加到用户表情";
button.addEventListener("mouseenter", () => {
if (!button.disabled) {
button.style.background = "var(--tertiary-high)";
button.style.transform = "scale(1.02)";
}
});
button.addEventListener("mouseleave", () => {
if (!button.disabled && !button.innerHTML.includes("已处理")) {
button.style.background = "var(--tertiary-very-low)";
button.style.transform = "scale(1)";
}
});
button.addEventListener("click", async (e) => {
e.preventDefault();
e.stopPropagation();
const originalHTML = button.innerHTML;
const originalStyle = button.style.cssText;
try {
button.innerHTML = "正在解析...";
button.style.background = "var(--primary-medium)";
button.disabled = true;
const lightboxWrappers = cookedElement.querySelectorAll(".lightbox-wrapper");
const allEmojiData = [];
lightboxWrappers.forEach((wrapper) => {
const items = extractEmojiDataFromLightboxWrapper(wrapper);
allEmojiData.push(...items);
});
if (allEmojiData.length === 0) throw new Error("未找到可解析的图片");
let successCount = 0;
for (const emojiData of allEmojiData) try {
addEmojiToUserscript(emojiData);
successCount++;
} catch (e$1) {
console.error("[Userscript OneClick] 添加图片失败", emojiData.name, e$1);
}
button.innerHTML = `
已处理 ${successCount}/${allEmojiData.length} 张图片
`;
button.style.background = "linear-gradient(135deg, #10b981, #059669)";
setTimeout(() => {
button.innerHTML = originalHTML;
button.style.cssText = originalStyle;
button.disabled = false;
}, 3e3);
} catch (error) {
console.error("[Userscript OneClick] Batch parse failed:", error);
button.innerHTML = `
解析失败
`;
button.style.background = "linear-gradient(135deg, #ef4444, #dc2626)";
setTimeout(() => {
button.innerHTML = originalHTML;
button.style.cssText = originalStyle;
button.disabled = false;
}, 3e3);
}
});
return button;
}
function processCookedContent(cookedElement) {
if (cookedElement.querySelector(".emoji-batch-parse-button")) return;
if (!cookedElement.querySelector(".lightbox-wrapper")) return;
const batchButton = createBatchParseButton(cookedElement);
cookedElement.insertBefore(batchButton, cookedElement.firstChild);
}
function processCookedContents() {
document.querySelectorAll(".cooked").forEach((element) => {
if (element.classList.contains("cooked") && element.querySelector(".lightbox-wrapper")) processCookedContent(element);
});
}
function initBatchParseButtons() {
setTimeout(processCookedContents, 500);
new MutationObserver((mutations) => {
let hasNewCooked = 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("cooked")) hasNewCooked = true;
if (element.querySelectorAll && element.querySelectorAll(".cooked").length > 0) hasNewCooked = true;
}
});
});
if (hasNewCooked) setTimeout(processCookedContents, 100);
}).observe(document.body, {
childList: true,
subtree: true
});
}
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(data) {
const button = createEl("a", {
className: "emoji-add-link",
style: `
color:#fff;
border-radius:6px;
padding:4px 8px;
margin:0 2px;
display:inline-flex;
align-items:center;
font-weight:600;
text-decoration:none;
border: 1px solid rgba(255,255,255,0.7);
cursor: pointer;
`,
title: "添加到未分组表情"
});
button.innerHTML = `添加表情`;
function addToUngrouped(emoji) {
const data$1 = loadDataFromLocalStorage();
let group = data$1.emojiGroups.find((g) => g.id === "ungrouped");
if (!group) {
group = {
id: "ungrouped",
name: "未分组",
icon: "📦",
order: 999,
emojis: []
};
data$1.emojiGroups.push(group);
}
if (!group.emojis.some((e) => e.url === emoji.url || e.name === emoji.name)) {
group.emojis.push({
packet: Date.now(),
name: emoji.name,
url: emoji.url
});
saveDataToLocalStorage({ emojiGroups: data$1.emojiGroups });
}
}
button.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
try {
addToUngrouped({
name: data.name,
url: data.url
});
const original = button.textContent || "";
button.textContent = "已添加";
button.style.background = "linear-gradient(135deg,#10b981,#059669)";
setTimeout(() => {
button.textContent = original || "添加表情";
button.style.background = "";
}, 1500);
} catch (err) {
console.warn("[Userscript] add emoji failed", err);
const original = button.textContent || "";
button.textContent = "失败";
button.style.background = "linear-gradient(135deg,#ef4444,#dc2626)";
setTimeout(() => {
button.textContent = original || "添加表情";
button.style.background = "linear-gradient(135deg, #4f46e5, #7c3aed)";
}, 1500);
}
});
return button;
}
function addEmojiButtonToPswp(container) {
const topBar = container.querySelector(".pswp__top-bar") || (container.classList.contains("pswp__top-bar") ? container : null);
if (!topBar) return;
if (topBar.querySelector(".emoji-add-link")) return;
const originalBtn = topBar.querySelector(".pswp__button--original-image");
const downloadBtn = topBar.querySelector(".pswp__button--download-image");
let imgUrl = "";
if (originalBtn?.href) imgUrl = originalBtn.href;
else if (downloadBtn?.href) imgUrl = downloadBtn.href;
if (!imgUrl) return;
let name = "";
const captionTitle = document.querySelector(".pswp__caption-title");
if (captionTitle?.textContent?.trim()) name = captionTitle.textContent.trim();
if (!name) {
if (originalBtn?.title) name = originalBtn.title;
else if (downloadBtn?.title) name = downloadBtn.title;
}
if (!name || name.length < 2) name = extractNameFromUrl(imgUrl);
name = name.trim() || "表情";
const addButton = createAddButton({
name,
url: imgUrl
});
if (downloadBtn?.parentElement) downloadBtn.parentElement.insertBefore(addButton, downloadBtn.nextSibling);
else topBar.appendChild(addButton);
}
function scanForPhotoSwipeTopBar() {
document.querySelectorAll(".pswp__top-bar").forEach((topBar) => addEmojiButtonToPswp(topBar));
}
function observePhotoSwipeTopBar() {
scanForPhotoSwipeTopBar();
function debounce(fn, wait = 100) {
let timer = null;
return (...args) => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
timer = null;
fn(...args);
}, wait);
};
}
const debouncedScan = debounce(scanForPhotoSwipeTopBar, 100);
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === "childList" && (m.addedNodes.length > 0 || m.removedNodes.length > 0)) {
debouncedScan();
return;
}
if (m.type === "attributes") {
debouncedScan();
return;
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: false
});
return observer;
}
function initPhotoSwipeTopbarUserscript() {
scanForPhotoSwipeTopBar();
observePhotoSwipeTopBar();
}
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() {
return detectRuntimePlatform();
}
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",
".d-editor-button-bar"
];
switch (platform) {
case "mobile": return [
...baseSelectors,
".mobile-composer .d-editor-button-bar",
".discourse-mobile .d-editor-button-bar",
"[data-mobile-toolbar]"
];
case "pc": return [
...baseSelectors,
".desktop-composer .d-editor-button-bar",
".discourse-desktop .d-editor-button-bar",
"[data-desktop-toolbar]"
];
default: return baseSelectors;
}
}
function logPlatformInfo() {
const buildPlatform = "original";
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);
}
var _sharedPreview = null;
function ensureHoverPreview() {
if (_sharedPreview && document.body.contains(_sharedPreview)) return _sharedPreview;
_sharedPreview = 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;"
});
_sharedPreview.appendChild(img);
_sharedPreview.appendChild(label);
document.body.appendChild(_sharedPreview);
return _sharedPreview;
}
var themeStylesInjected = false;
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);
}
}
`
}));
}
function ensureStyleInjected(id, css) {
const style = document.createElement("style");
style.id = id;
style.textContent = css;
document.documentElement.appendChild(style);
}
function injectEmojiPickerStyles() {
if (typeof document === "undefined") return;
if (document.getElementById("emoji-picker-styles")) return;
injectGlobalThemeStyles();
ensureStyleInjected("emoji-picker-styles", `
.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;
}
`);
}
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;
}
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 selectors = [
"textarea.d-editor-input",
"textarea.ember-text-area",
"#channel-composer",
".chat-composer__input",
"textarea.chat-composer__input"
];
const proseMirror = document.querySelector(".ProseMirror.d-editor-input");
let textarea = null;
for (const s of selectors) {
const el = document.querySelector(s);
if (el) {
textarea = el;
break;
}
}
const contentEditable = document.querySelector("[contenteditable=\"true\"]");
if (!textarea && !proseMirror && !contentEditable) {
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 = ` `;
} 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 = `
`;
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);
}
}
} else if (contentEditable) try {
if (outputFormat === "html") {
const imgWidth = Number(width) || 500;
const scaledWidth = Math.max(1, Math.round(imgWidth * (scale / 100)));
const htmlContent = `
`;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
const frag = document.createRange().createContextualFragment(htmlContent);
range.deleteContents();
range.insertNode(frag);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
} else contentEditable.insertAdjacentHTML("beforeend", htmlContent);
} else {
const insertText = ` `;
const textNode = document.createTextNode(insertText);
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
range.insertNode(textNode);
range.setStartAfter(textNode);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else contentEditable.appendChild(textNode);
}
const inputEvent = new Event("input", {
bubbles: true,
cancelable: true
});
contentEditable.dispatchEvent(inputEvent);
} catch (e) {
console.error("无法向 contenteditable 插入表情", e);
}
}
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: ``,
on: { click: () => {
(modal.closest(".modal-container") || modal)?.remove();
document.querySelector(".d-modal__backdrop")?.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 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",
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" });
titleContainer.appendChild(createEl("h2", {
className: "emoji-picker__section-title",
text: group.name
}));
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();
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 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);
}
function showTemporaryMessage(message, duration = 2e3) {
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 = 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.head.appendChild(style);
}
document.body.appendChild(messageEl);
setTimeout(() => {
try {
messageEl.remove();
} catch {}
}, duration);
}
function createModalElement(options) {
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;
`,
className: options.className
});
const content = createEl("div", { style: `
background: var(--secondary);
color: var(--emoji-modal-text);
border: 1px solid var(--emoji-modal-border);
border-radius: 8px;
padding: 24px;
max-width: 90%;
max-height: 90%;
overflow-y: auto;
position: relative;
` });
if (options.title) {
const titleElement = createEl("div", {
style: `
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
`,
innerHTML: `