Greasy Fork is available in English.
在 2libra 评论编辑器中添加自定义表情收藏与插入面板,支持粘贴链接、右键收藏、快捷键等能力。
// ==UserScript==
// @name 2libra 自定义表情收藏
// @namespace https://2libra.com/
// @version 1.0.2
// @description 在 2libra 评论编辑器中添加自定义表情收藏与插入面板,支持粘贴链接、右键收藏、快捷键等能力。
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @grant GM_addValueChangeListener
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// 如注入失败,可调整 EDITOR_ROOT_SELECTORS 或 TOOLBAR_TARGET_SELECTOR 以适配站点 DOM。
const STORAGE_KEY = 'emoji_favs_v1';
const TOGGLE_HOTKEY = { altKey: true, key: 'e' };
const INSERT_TEMPLATE = (url) => ``;
const SITE_ENABLE_PREFIX = 'emoji_site_enable_';
const PRIMARY_HOST = '2libra.com';
const STICKY_PANEL_CLASS = 'my-emoji-sticky-panel';
const STICKY_BTN_CLASS = 'my-emoji-sticky-btn';
const STICKY_PANEL_SIZE = { width: 350, height: 400 };
const EDITOR_ROOT_SELECTORS = [
'.md-editor.wmde-markdown-var.w-md-editor.w-md-editor-show-edit',
'.md-editor.w-md-editor.w-md-editor-show-edit',
'.md-editor.w-md-editor',
'.w-md-editor.w-md-editor-show-edit',
'.w-md-editor',
];
const TOOLBAR_TARGET_SELECTOR = '.w-md-editor-toolbar > ul:first-of-type';
const PANEL_SIZE = { width: 440, height: 420 };
let favorites = loadFavorites();
let panelVisible = false;
let lastActiveEditorRoot = null;
let lastActiveInput = null;
let lastContextImageUrl = '';
let mutationTimer = null;
let activeItemMenuId = '';
let currentSizeKey = 'md';
let dragImageUrl = '';
let stickyPanelStyleInjected = false;
let editorUidCounter = 1;
const loadedImageCache = new Set();
const currentHost = location.host;
const siteEnabled = isSiteEnabled(currentHost);
if (!siteEnabled) {
registerEnableMenu();
return;
}
const ui = createUI();
renderFavorites();
setupGlobalListeners();
observeEditors();
registerMenu();
setupStorageSync();
setupStickyPanelStyles();
function loadFavorites() {
const stored = GM_getValue(STORAGE_KEY, []);
return Array.isArray(stored) ? stored : [];
}
function isSiteEnabled(host) {
if (host.includes(PRIMARY_HOST)) return true;
return GM_getValue(SITE_ENABLE_PREFIX + host, false);
}
function registerEnableMenu() {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand('在该站点启用收藏表情包脚本', () => {
GM_setValue(SITE_ENABLE_PREFIX + currentHost, true);
alert('已在该站点启用,页面将刷新以生效');
location.reload();
});
}
function saveFavorites(list) {
favorites = list;
GM_setValue(STORAGE_KEY, favorites);
renderFavorites();
refreshStickyPanels();
}
function shortHash(str) {
let hash = 0;
for (let i = 0; i < str.length; i += 1) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash).toString(36).slice(0, 6);
}
function deriveNameFromUrl(urlStr) {
try {
const u = new URL(urlStr);
const segments = u.pathname.split('/').filter(Boolean);
if (segments.length) {
const raw = decodeURIComponent(segments[segments.length - 1]);
const cleaned = raw.replace(/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i, '').trim();
if (cleaned) return cleaned;
}
return `${u.host}-${shortHash(urlStr)}`;
} catch (e) {
return `emoji-${shortHash(urlStr)}`;
}
}
function extractUrl(input) {
if (!input) return null;
let str = input.trim();
const mdMatch = str.match(/!\[[^\]]*]\(\s*<?([^)>]+)>?\s*\)/);
if (mdMatch && mdMatch[1]) {
str = mdMatch[1].trim();
} else if (str.startsWith('<') && str.endsWith('>')) {
str = str.slice(1, -1).trim();
}
try {
// eslint-disable-next-line no-new
new URL(str);
return str;
} catch (e) {
return null;
}
}
function sanitizeUrl(raw) {
if (!raw) return null;
let u;
try {
u = new URL(raw);
} catch (e) {
return null;
}
if (currentHost.includes(PRIMARY_HOST)) {
const params = new URLSearchParams(u.search);
if (params.has('size')) {
params.delete('size');
const nextSearch = params.toString();
u.search = nextSearch ? `?${nextSearch}` : '';
}
}
return u.toString();
}
function toast(message) {
const toast = document.createElement('div');
toast.className = 'my-emoji-toast';
toast.textContent = message;
ui.toastHost.appendChild(toast);
requestAnimationFrame(() => {
toast.classList.add('show');
});
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => toast.remove(), 200);
}, 2200);
}
function createUI() {
const host = document.createElement('div');
host.id = 'my-emoji-root';
host.style.position = 'fixed';
host.style.zIndex = '999999';
host.style.pointerEvents = 'none';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = `
:host { all: initial; }
.toggle-btn {
position: fixed;
right: 16px;
bottom: 16px;
width: 42px;
height: 42px;
border-radius: 50%;
border: none;
background: rgba(17,24,39,0.9);
color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
cursor: pointer;
font-weight: 700;
pointer-events: auto;
}
.panel {
position: fixed;
right: 16px;
bottom: 68px;
width: ${PANEL_SIZE.width}px;
height: ${PANEL_SIZE.height}px;
background: rgba(11, 16, 33, 0.85);
color: #f8fafc;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0,0,0,0.35);
display: none;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
backdrop-filter: blur(8px);
}
.panel.visible { display: flex; }
.panel-header {
display: flex;
gap: 8px;
padding: 10px;
align-items: center;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.size-row {
display: flex;
gap: 8px;
padding: 10px;
border-bottom: 1px solid rgba(255,255,255,0.08);
align-items: center;
flex-wrap: wrap;
}
.size-row .label {
font-size: 12px;
color: #cbd5e1;
}
.size-row button {
background: rgba(255,255,255,0.08);
color: #e2e8f0;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.size-row button.active {
background: #2563eb;
border-color: #2563eb;
color: #fff;
}
.panel-header input {
flex: 1;
background: #111827;
border: 1px solid rgba(255,255,255,0.12);
color: #e2e8f0;
border-radius: 6px;
padding: 6px 8px;
font-size: 12px;
}
.panel-header button {
background: #2563eb;
color: #fff;
border: none;
border-radius: 6px;
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
}
.list {
padding: 8px;
display: grid;
grid-template-columns: repeat(6, 60px);
gap: 8px;
overflow: auto;
flex: 1;
align-content: flex-start;
}
.item {
display: flex;
flex-direction: column;
gap: 4px;
cursor: pointer;
}
.thumb {
position: relative;
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
background: #111827;
display: flex;
align-items: center;
justify-content: center;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.caption {
color: #e2e8f0;
font-size: 12px;
line-height: 1.3;
text-align: center;
padding: 0 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.toast-wrap {
position: fixed;
right: 16px;
bottom: 16px;
display: flex;
flex-direction: column;
gap: 8px;
pointer-events: none;
}
.my-emoji-toast {
background: #111827;
color: #f8fafc;
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 6px 18px rgba(0,0,0,0.25);
transform: translateY(10px);
opacity: 0;
transition: all 0.2s ease;
}
.my-emoji-toast.show {
transform: translateY(0);
opacity: 1;
}
.ctx-menu {
position: fixed;
background: rgba(11, 16, 33, 0.92);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
padding: 6px;
display: none;
pointer-events: auto;
backdrop-filter: blur(8px);
}
.ctx-menu button {
background: none;
border: none;
color: #e2e8f0;
padding: 6px 10px;
width: 100%;
text-align: left;
cursor: pointer;
border-radius: 6px;
}
.ctx-menu button:hover {
background: rgba(255,255,255,0.08);
}
.item-menu {
position: fixed;
background: rgba(11, 16, 33, 0.92);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
padding: 6px;
display: none;
pointer-events: auto;
backdrop-filter: blur(8px);
min-width: 140px;
}
.item-menu button {
background: none;
border: none;
color: #e2e8f0;
padding: 6px 10px;
width: 100%;
text-align: left;
cursor: pointer;
border-radius: 6px;
}
.item-menu button:hover {
background: rgba(255,255,255,0.08);
}
.tray {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: 120px;
background: rgba(11, 16, 33, 0.9);
color: #e2e8f0;
display: none;
align-items: center;
justify-content: center;
border-top: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 -8px 20px rgba(0,0,0,0.25);
pointer-events: auto;
backdrop-filter: blur(8px);
z-index: 1;
}
.tray.visible {
display: flex;
}
.tray .inner {
text-align: center;
font-size: 14px;
padding: 12px 18px;
border: 1px dashed rgba(255,255,255,0.4);
border-radius: 12px;
}
`;
shadow.appendChild(style);
const panel = document.createElement('div');
panel.className = 'panel';
const sizeRow = document.createElement('div');
sizeRow.className = 'size-row';
const sizeLabel = document.createElement('span');
sizeLabel.className = 'label';
sizeLabel.textContent = '尺寸';
sizeRow.appendChild(sizeLabel);
const sizeOptions = [
{ key: 'sm', label: 'sm' },
{ key: 'md', label: 'md' },
{ key: 'lg', label: 'lg' },
{ key: 'origin', label: '原图' },
];
sizeOptions.forEach((opt) => {
const btn = document.createElement('button');
btn.textContent = opt.label;
btn.dataset.size = opt.key;
if (opt.key === currentSizeKey) btn.classList.add('active');
btn.addEventListener('click', () => {
currentSizeKey = opt.key;
Array.from(sizeRow.querySelectorAll('button')).forEach((b) => b.classList.remove('active'));
btn.classList.add('active');
});
sizeRow.appendChild(btn);
});
panel.appendChild(sizeRow);
const header = document.createElement('div');
header.className = 'panel-header';
const input = document.createElement('input');
input.type = 'text';
input.placeholder = '粘贴图片链接';
input.autocomplete = 'off';
const addBtn = document.createElement('button');
addBtn.textContent = '添加';
addBtn.addEventListener('click', () => addByInput());
header.appendChild(input);
header.appendChild(addBtn);
const list = document.createElement('div');
list.className = 'list';
list.addEventListener('click', onListClick);
list.addEventListener('contextmenu', onListContextMenu);
panel.appendChild(header);
panel.appendChild(list);
shadow.appendChild(panel);
const toastWrap = document.createElement('div');
toastWrap.className = 'toast-wrap';
shadow.appendChild(toastWrap);
const ctxMenu = document.createElement('div');
ctxMenu.className = 'ctx-menu';
const ctxBtn = document.createElement('button');
ctxBtn.textContent = '添加到自定义表情';
ctxBtn.addEventListener('click', () => {
ctxMenu.style.display = 'none';
if (lastContextImageUrl) {
addFavorite(lastContextImageUrl);
} else {
toast('没有找到图片链接');
}
});
ctxMenu.appendChild(ctxBtn);
shadow.appendChild(ctxMenu);
const itemMenu = document.createElement('div');
itemMenu.className = 'item-menu';
itemMenu.innerHTML = `
<button data-action="copy">复制 Markdown</button>
<button data-action="rename">重命名</button>
<button data-action="editUrl">编辑链接</button>
<button data-action="delete">删除</button>
`;
shadow.appendChild(itemMenu);
const tray = document.createElement('div');
tray.className = 'tray';
tray.innerHTML = `<div class="inner">拖到这里收藏表情</div>`;
shadow.appendChild(tray);
return {
host,
shadow,
panel,
input,
addBtn,
list,
toastHost: toastWrap,
ctxMenu,
itemMenu,
tray,
};
}
function renderFavorites(options = {}) {
const { loadImages = panelVisible } = options;
const listEl = ui.list;
listEl.innerHTML = '';
favorites.forEach((fav) => {
const item = document.createElement('div');
item.className = 'item';
item.dataset.id = fav.id;
const thumb = document.createElement('div');
thumb.className = 'thumb';
const img = document.createElement('img');
img.alt = fav.name;
if (loadImages) {
img.src = fav.url;
img.addEventListener('load', () => loadedImageCache.add(fav.url), { once: true });
} else {
if (loadedImageCache.has(fav.url)) {
img.src = fav.url;
} else {
img.dataset.src = fav.url;
}
}
thumb.appendChild(img);
const caption = document.createElement('div');
caption.className = 'caption';
caption.title = fav.name;
caption.textContent = fav.name;
item.appendChild(thumb);
item.appendChild(caption);
listEl.appendChild(item);
});
}
function loadPanelImages() {
ui.list.querySelectorAll('img[data-src]').forEach((img) => {
if (!(img instanceof HTMLImageElement)) return;
const src = img.dataset.src;
if (!src) return;
img.src = src;
img.addEventListener('load', () => loadedImageCache.add(src), { once: true });
img.removeAttribute('data-src');
});
}
function togglePanel(force) {
const shouldShow = typeof force === 'boolean' ? force : !panelVisible;
panelVisible = shouldShow;
ui.panel.classList.toggle('visible', shouldShow);
if (shouldShow) {
loadPanelImages();
ui.input.focus();
} else {
hideItemMenu();
}
}
function addByInput() {
const raw = ui.input.value.trim();
if (!raw) {
toast('请输入图片链接');
return;
}
const parsed = extractUrl(raw);
if (!parsed) {
toast('无效的链接');
return;
}
addFavorite(parsed);
ui.input.value = '';
}
function addFavorite(url) {
const cleanUrl = sanitizeUrl(url.trim());
if (!cleanUrl) {
toast('链接为空');
return;
}
// Validate URL
// eslint-disable-next-line no-new
try { new URL(cleanUrl); } catch (e) { toast('无效的链接'); return; }
if (favorites.some((f) => f.url === cleanUrl)) {
toast('已收藏');
return;
}
const now = Date.now();
const entry = {
id: `fav_${now}_${shortHash(cleanUrl)}`,
name: deriveNameFromUrl(cleanUrl),
url: cleanUrl,
createdAt: now,
sourcePageUrl: location.href,
};
const next = [entry, ...favorites];
saveFavorites(next);
toast('已添加到收藏');
}
function onListClick(e) {
const item = e.target.closest('.item');
if (!item) return;
const fav = favorites.find((f) => f.id === item.dataset.id);
if (!fav) return;
insertFavorite(fav);
hideItemMenu();
togglePanel(false);
}
function onListContextMenu(e) {
const item = e.target.closest('.item');
if (!item) return;
e.preventDefault();
e.stopPropagation();
if (!favorites.some((f) => f.id === item.dataset.id)) return;
activeItemMenuId = item.dataset.id;
showItemMenu(e.clientX, e.clientY);
}
function copyMarkdown(markdown) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(markdown, { type: 'text', mimetype: 'text/plain' });
toast('已复制 Markdown');
return;
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(markdown).then(() => toast('已复制 Markdown'), () => fallbackCopy(markdown));
return;
}
fallbackCopy(markdown);
}
function fallbackCopy(text) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.top = '-200px';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
ta.remove();
toast('已复制 Markdown');
}
function insertFavorite(fav) {
const markdown = INSERT_TEMPLATE(applySizeToUrl(fav.url));
const target = resolveTextareaTarget();
if (target && target.textarea) {
insertAtCursor(target.textarea, markdown);
toast('已插入到编辑器');
} else {
copyMarkdown(markdown);
toast('未找到编辑器,已复制');
}
}
function applySizeToUrl(url) {
if (!url) return url;
if (currentSizeKey === 'origin') return url;
return `${url}#inline-${currentSizeKey}`;
}
function applyInlineMd(url) {
if (!url) return url;
const hashIndex = url.indexOf('#');
const base = hashIndex >= 0 ? url.slice(0, hashIndex) : url;
return `${base}#inline-md`;
}
function insertAtCursor(textarea, text) {
if (!textarea) return;
const value = textarea.value || '';
const start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : value.length;
const end = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : start;
const expectedIndex = start;
const expectedEnd = start + text.length;
if (typeof textarea.setRangeText === 'function') {
textarea.setRangeText(text, start, end, 'end');
} else {
const next = value.slice(0, start) + text + value.slice(end);
setNativeValue(textarea, next);
const newPos = expectedEnd;
textarea.selectionStart = textarea.selectionEnd = newPos;
}
dispatchInputEvents(textarea, text);
textarea.focus();
scheduleInsertVerification(textarea, text, expectedIndex, expectedEnd);
}
function dispatchInputEvents(textarea, text) {
let inputEvent;
try {
inputEvent = new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' });
} catch (e) {
inputEvent = new Event('input', { bubbles: true });
}
textarea.dispatchEvent(inputEvent);
textarea.dispatchEvent(new Event('change', { bubbles: true }));
}
function scheduleInsertVerification(textarea, text, expectedIndex, expectedEnd) {
const attempts = [60, 260];
attempts.forEach((delay) => {
setTimeout(() => {
if (!textarea || !textarea.isConnected) return;
const current = textarea.value || '';
if (current.slice(expectedIndex, expectedEnd) === text) return;
if (current.includes(text)) return;
const endPos = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : current.length;
if (typeof textarea.setRangeText === 'function') {
textarea.setRangeText(text, endPos, endPos, 'end');
} else {
setNativeValue(textarea, current + text);
textarea.selectionStart = textarea.selectionEnd = current.length + text.length;
}
dispatchInputEvents(textarea, text);
}, delay);
});
}
function setNativeValue(textarea, value) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value');
if (descriptor && descriptor.set) {
descriptor.set.call(textarea, value);
} else {
textarea.value = value;
}
}
function resolveTextareaTarget() {
let root = null;
if (lastActiveInput instanceof HTMLTextAreaElement && lastActiveInput.isConnected) {
const maybeRoot = findEditorRootForNode(lastActiveInput);
if (maybeRoot) {
root = maybeRoot;
lastActiveEditorRoot = root;
}
}
if (!root || !root.isConnected) {
if (lastActiveEditorRoot && lastActiveEditorRoot.isConnected) {
root = lastActiveEditorRoot;
} else {
root = findLatestEditorRoot();
}
}
if (!root) return null;
const textarea = root.querySelector('textarea');
if (textarea) {
lastActiveInput = textarea;
lastActiveEditorRoot = root;
return { root, textarea };
}
return null;
}
function findLatestEditorRoot() {
for (const selector of EDITOR_ROOT_SELECTORS) {
const nodes = Array.from(document.querySelectorAll(selector));
if (nodes.length) {
return nodes[nodes.length - 1];
}
}
return null;
}
function findEditorRootForNode(node) {
if (!node) return null;
let el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
while (el) {
if (EDITOR_ROOT_SELECTORS.some((sel) => el.matches && el.matches(sel))) {
return el;
}
el = el.parentElement;
}
return null;
}
function ensureInjectedIntoToolbar(editorRoot) {
if (!editorRoot || editorRoot.dataset.myEmojiInjected === '1') return;
const target = editorRoot.querySelector(TOOLBAR_TARGET_SELECTOR);
if (!target) return;
target.appendChild(createStickyToolbarButton(editorRoot));
editorRoot.dataset.myEmojiInjected = '1';
}
function observeEditors() {
checkEditors();
const observer = new MutationObserver(() => {
if (mutationTimer) return;
mutationTimer = setTimeout(() => {
mutationTimer = null;
checkEditors();
}, 200);
});
observer.observe(document.body, { childList: true, subtree: true });
}
function checkEditors() {
const roots = findAllEditorRoots();
roots.forEach((root) => ensureInjectedIntoToolbar(root));
}
function findAllEditorRoots() {
const set = new Set();
EDITOR_ROOT_SELECTORS.forEach((sel) => {
document.querySelectorAll(sel).forEach((node) => set.add(node));
});
return Array.from(set);
}
function setupGlobalListeners() {
document.addEventListener('mousedown', (e) => {
const path = e.composedPath();
if (panelVisible) {
if (!path.includes(ui.panel) && !path.includes(ui.itemMenu)) {
togglePanel(false);
}
}
if (!path.includes(ui.ctxMenu)) {
ui.ctxMenu.style.display = 'none';
}
if (!path.includes(ui.itemMenu)) {
hideItemMenu();
}
const stickyPanel = document.querySelector(`.${STICKY_PANEL_CLASS}.visible`);
if (stickyPanel) {
const stickyBtn = document.querySelector(`.${STICKY_BTN_CLASS}`);
if (stickyBtn && !stickyPanel.contains(e.target) && !stickyBtn.contains(e.target)) {
stickyPanel.classList.remove('visible');
}
}
});
document.addEventListener('click', (e) => {
const settingsBtn = e.target.closest && e.target.closest('.header-settings-btn');
if (!settingsBtn) return;
const stickyPanel = document.querySelector(`.${STICKY_PANEL_CLASS}.visible`);
if (stickyPanel) stickyPanel.classList.remove('visible');
const root = findLatestEditorRoot();
if (root) {
lastActiveEditorRoot = root;
const ta = root.querySelector('textarea');
if (ta) lastActiveInput = ta;
}
togglePanel(true);
});
document.addEventListener('keydown', (e) => {
if (e.altKey === TOGGLE_HOTKEY.altKey && e.key.toLowerCase() === TOGGLE_HOTKEY.key) {
e.preventDefault();
togglePanel();
}
});
document.addEventListener('focusin', (e) => {
const root = findEditorRootForNode(e.target);
if (root) {
lastActiveEditorRoot = root;
if (e.target instanceof HTMLTextAreaElement) {
lastActiveInput = e.target;
}
ensureInjectedIntoToolbar(root);
}
});
document.addEventListener('contextmenu', (e) => {
const img = e.target.closest ? e.target.closest('img') : null;
if (!img) return;
lastContextImageUrl = img.currentSrc || img.src || '';
if (!lastContextImageUrl) return;
showContextMenu(e.pageX, e.pageY);
});
document.addEventListener('click', (e) => {
const path = e.composedPath();
if (!path.includes(ui.ctxMenu)) {
ui.ctxMenu.style.display = 'none';
}
if (!path.includes(ui.itemMenu)) {
hideItemMenu();
}
});
document.addEventListener('dragstart', (e) => {
const img = e.target instanceof Element && e.target.closest ? e.target.closest('img') : null;
if (!img) return;
const url = img.currentSrc || img.src || '';
if (!url) return;
dragImageUrl = url;
showTray(true);
});
document.addEventListener('dragend', () => {
dragImageUrl = '';
showTray(false);
});
ui.tray.addEventListener('dragover', (e) => {
e.preventDefault();
});
ui.tray.addEventListener('drop', (e) => {
e.preventDefault();
const fromData =
e.dataTransfer?.getData('text/uri-list') ||
e.dataTransfer?.getData('text/plain') ||
'';
const candidate = dragImageUrl || fromData.trim();
const url = extractUrl(candidate);
dragImageUrl = '';
showTray(false);
if (url) {
addFavorite(url);
} else {
toast('未识别到图片链接');
}
});
ui.itemMenu.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const fav = favorites.find((f) => f.id === activeItemMenuId);
hideItemMenu();
if (!fav) return;
const action = btn.dataset.action;
if (action === 'copy') {
copyMarkdown(INSERT_TEMPLATE(fav.url));
} else if (action === 'rename') {
const name = window.prompt('重命名表情', fav.name);
if (name && name.trim()) {
fav.name = name.trim();
saveFavorites([...favorites]);
}
} else if (action === 'editUrl') {
const urlInput = window.prompt('编辑表情链接', fav.url);
if (!urlInput) return;
const parsed = extractUrl(urlInput);
const sanitized = parsed ? sanitizeUrl(parsed) : null;
if (!sanitized) {
toast('无效的链接');
return;
}
if (favorites.some((f) => f.id !== fav.id && f.url === sanitized)) {
toast('该链接已在收藏中');
return;
}
fav.url = sanitized;
saveFavorites([...favorites]);
} else if (action === 'delete') {
if (window.confirm('确认删除该表情吗?')) {
saveFavorites(favorites.filter((f) => f.id !== fav.id));
}
}
});
}
function showContextMenu(x, y) {
const menu = ui.ctxMenu;
menu.style.display = 'block';
const rect = menu.getBoundingClientRect();
const maxX = window.innerWidth - rect.width - 8;
const maxY = window.innerHeight - rect.height - 8;
menu.style.left = `${Math.min(x, maxX)}px`;
menu.style.top = `${Math.min(y, maxY)}px`;
}
function showItemMenu(x, y) {
const menu = ui.itemMenu;
if (!activeItemMenuId) return;
menu.style.display = 'block';
const rect = menu.getBoundingClientRect();
const maxX = window.innerWidth - rect.width - 8;
const maxY = window.innerHeight - rect.height - 8;
menu.style.left = `${Math.min(x, maxX)}px`;
menu.style.top = `${Math.min(y, maxY)}px`;
}
function hideItemMenu() {
ui.itemMenu.style.display = 'none';
activeItemMenuId = '';
}
function showTray(show) {
ui.tray.classList.toggle('visible', !!show);
}
function registerMenu() {
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('添加到自定义表情(最近右键图片)', () => {
if (lastContextImageUrl) {
addFavorite(lastContextImageUrl);
} else {
toast('请先右键图片');
}
});
GM_registerMenuCommand('在该站点禁用收藏表情包脚本', () => {
GM_setValue(SITE_ENABLE_PREFIX + currentHost, false);
alert('已禁用该站点,页面将刷新');
location.reload();
});
}
}
function setupStorageSync() {
if (typeof GM_addValueChangeListener !== 'function') return;
GM_addValueChangeListener(STORAGE_KEY, (_key, _oldValue, newValue, remote) => {
if (!remote) return;
favorites = Array.isArray(newValue) ? newValue : [];
renderFavorites();
refreshStickyPanels();
});
}
function setupStickyPanelStyles() {
if (stickyPanelStyleInjected) return;
stickyPanelStyleInjected = true;
const style = document.createElement('style');
style.textContent = `
.${STICKY_PANEL_CLASS} {
position: absolute;
width: ${STICKY_PANEL_SIZE.width}px;
height: ${STICKY_PANEL_SIZE.height}px;
background: var(--color-base-100, #fff);
border: 1px solid var(--color-base-300, #e7e7e7);
border-radius: 0.5rem;
box-shadow: 0 0.25rem 0.75rem rgba(0,0,0,0.15);
display: none;
flex-direction: column;
overflow: hidden;
z-index: 1000;
margin-bottom: 0.5rem;
font-family: var(--default-font-family, Arial, Helvetica, sans-serif);
color: var(--color-base-content, #4b5563);
}
.${STICKY_PANEL_CLASS}.visible { display: flex; }
.${STICKY_PANEL_CLASS} .header {
display: flex;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--color-base-300, #e7e7e7);
font-size: 12px;
font-weight: 600;
color: var(--color-base-content, #555);
}
.${STICKY_PANEL_CLASS} .header-settings-btn {
font-size: 10px;
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
text-decoration: underline;
}
.${STICKY_PANEL_CLASS} .grid {
flex: 1;
padding: 10px;
display: grid;
grid-template-columns: repeat(auto-fill, 40px);
grid-auto-rows: 40px;
gap: 0px;
overflow: auto;
}
.${STICKY_PANEL_CLASS} .grid button {
width: 40px;
height: 40px;
border: none;
background: none;
cursor: pointer;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.${STICKY_PANEL_CLASS} .grid button:hover {
background: #e5f0fa;
}
.${STICKY_PANEL_CLASS} .grid img {
max-width: 30px;
max-height: 30px;
}
.${STICKY_PANEL_CLASS} .preview {
position: absolute;
right: 10px;
bottom: 10px;
width: 120px;
height: 120px;
border: 1px solid var(--color-base-300, #e7e7e7);
border-radius: 8px;
background: var(--color-base-100, #fff);
display: none;
align-items: center;
justify-content: center;
box-shadow: 0 0.25rem 0.75rem rgba(0,0,0,0.12);
z-index: 1;
}
.${STICKY_PANEL_CLASS} .preview.visible { display: flex; }
.${STICKY_PANEL_CLASS} .preview img {
max-width: 110px;
max-height: 110px;
}
.${STICKY_BTN_CLASS} svg { width: 14px; height: 14px; }
`;
document.head.appendChild(style);
}
function createStickyToolbarButton(editorRoot) {
const li = document.createElement('li');
li.style.listStyle = 'none';
const btn = document.createElement('button');
btn.type = 'button';
btn.className = STICKY_BTN_CLASS;
btn.title = 'Custom Sticker';
btn.setAttribute('aria-label', 'Custom Sticker(由"2libra 自定义表情收藏" 脚本注入)');
btn.style.border = 'none';
btn.style.background = 'none';
btn.style.cursor = 'pointer';
btn.style.color = 'inherit';
btn.innerHTML = `
<div>
<svg width="12" height="12" viewBox="0 0 1024 1024" fill="currentColor" aria-hidden="true">
<path d="M649.544704 266.415104c-28.084224 0-70.007808 24.864768-109.408256 64.889856l-27.710464 28.140544-28.130304-27.696128c-56.745984-55.878656-91.731968-65.142784-111.082496-65.142784-37.598208 0-68.864 10.505216-95.56992 37.190656-27.46368 27.389952-42.575872 63.772672-42.58816 102.477824 0 38.725632 15.112192 75.136 42.571776 102.535168 20.51584 20.498432 196.97152 216.699904 221.316096 243.78368 4.683776 4.333568 10.011648 4.989952 12.827648 4.989952 2.843648 0 8.242176-0.671744 12.936192-5.1456 99.913728-107.826176 219.148288-236.468224 221.402112-238.761984 27.717632-27.662336 42.829824-64.079872 42.812416-102.80448 0-38.704128-15.112192-75.087872-42.53184-102.45632C717.27616 279.36768 687.418368 266.415104 649.544704 266.415104z"></path>
<path d="M512 0C229.230592 0 0 229.229568 0 512c0 282.770432 229.230592 512 512 512s512-229.229568 512-512C1024 229.229568 794.770432 0 512 0zM774.262784 541.355008c-2.33984 2.369536-221.21984 238.61248-221.21984 238.61248-11.40224 11.408384-26.33216 17.093632-41.270272 17.093632-14.927872 0-29.897728-5.686272-41.295872-17.093632 0 0-200.116224-222.61248-220.732416-243.218432-72.214528-72.059904-72.214528-188.859392 0-260.881408 34.650112-34.617344 76.264448-48.736256 123.469824-48.736256 48.264192 0 98.685952 37.00736 138.789888 76.483584 38.849536-39.477248 90.894336-76.67712 137.539584-76.67712 48.82432 0 88.876032 17.753088 124.71808 53.526528C846.466048 352.487424 846.466048 469.286912 774.262784 541.355008z"></path>
</svg>
</div>
`;
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
lastActiveEditorRoot = editorRoot;
const ta = editorRoot.querySelector('textarea');
if (ta) lastActiveInput = ta;
toggleStickyPanel(editorRoot, btn);
});
li.appendChild(btn);
return li;
}
function toggleStickyPanel(editorRoot, btn) {
const panel = ensureStickyPanel(editorRoot);
if (!panel) return;
const willShow = !panel.classList.contains('visible');
panel.classList.toggle('visible', willShow);
if (willShow) {
positionStickyPanel(btn, panel);
renderStickyPanel(panel);
}
}
function ensureStickyPanel(editorRoot) {
if (!editorRoot.dataset.myEmojiUid) {
editorRoot.dataset.myEmojiUid = `my-emoji-${editorUidCounter++}`;
}
const ownerId = editorRoot.dataset.myEmojiUid;
const container = editorRoot.parentElement || editorRoot;
let panel = container.querySelector(`.${STICKY_PANEL_CLASS}[data-owner="${ownerId}"]`);
if (panel) return panel;
if (getComputedStyle(container).position === 'static') {
container.style.position = 'relative';
}
panel = document.createElement('div');
panel.className = STICKY_PANEL_CLASS;
panel.dataset.owner = ownerId;
panel.innerHTML = `
<div class="header" title="由'2libra 自定义表情收藏' 脚本注入">
<span>Custom Sticker</span>
<button class="header-settings-btn">Settings</button>
</div>
<div class="grid"></div>
<div class="preview"><img alt="预览"></div>
`;
container.appendChild(panel);
return panel;
}
function positionStickyPanel(btn, panel) {
const container = panel.offsetParent || panel.parentElement;
if (!container) return;
const rootRect = container.getBoundingClientRect();
const btnRect = btn.getBoundingClientRect();
let left = btnRect.left - rootRect.left;
let top = btnRect.bottom - rootRect.top + 6;
if (left + STICKY_PANEL_SIZE.width > rootRect.width) {
left = rootRect.width - STICKY_PANEL_SIZE.width - 8;
}
if (left < 8) left = 8;
if (top + STICKY_PANEL_SIZE.height > rootRect.height) {
top = btnRect.top - rootRect.top - STICKY_PANEL_SIZE.height - 6;
}
if (top < 8) top = 8;
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
}
function renderStickyPanel(panel) {
const grid = panel.querySelector('.grid');
const preview = panel.querySelector('.preview');
const previewImg = preview ? preview.querySelector('img') : null;
if (!grid) return;
grid.innerHTML = '';
const observer = getStickyLazyObserver(panel, grid);
favorites.forEach((fav) => {
const btn = document.createElement('button');
btn.type = 'button';
const img = document.createElement('img');
img.alt = fav.name;
img.loading = 'lazy';
if (loadedImageCache.has(fav.url)) {
img.src = fav.url;
} else {
img.dataset.src = fav.url;
}
btn.appendChild(img);
if (preview && previewImg) {
btn.addEventListener('mouseenter', () => {
previewImg.src = fav.url;
previewImg.alt = fav.name;
preview.classList.add('visible');
});
btn.addEventListener('mouseleave', () => {
preview.classList.remove('visible');
});
}
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
insertStickyFavorite(fav);
panel.classList.remove('visible');
});
grid.appendChild(btn);
observer.observe(img);
});
}
function insertStickyFavorite(fav) {
const url = applyInlineMd(fav.url);
const markdown = INSERT_TEMPLATE(url);
const target = resolveTextareaTarget();
if (target && target.textarea) {
insertAtCursor(target.textarea, markdown);
toast('已插入到编辑器');
} else {
copyMarkdown(markdown);
toast('未找到编辑器,已复制');
}
}
function refreshStickyPanels() {
document.querySelectorAll(`.${STICKY_PANEL_CLASS}.visible`).forEach((panel) => {
renderStickyPanel(panel);
});
}
function getStickyLazyObserver(panel, grid) {
if (panel.__lazyObserver) {
panel.__lazyObserver.disconnect();
}
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
if (!(img instanceof HTMLImageElement)) return;
if (img.src) {
observer.unobserve(img);
return;
}
const src = img.dataset.src;
if (src) {
img.src = src;
img.addEventListener('load', () => loadedImageCache.add(src), { once: true });
}
observer.unobserve(img);
});
},
{
root: grid,
rootMargin: '40px',
threshold: 0.01,
}
);
panel.__lazyObserver = observer;
return observer;
}
})();