// ==UserScript== // @name 牛牛聊天发图片插件 // @namespace https://www.milkywayidle.com/ // @version 0.1.7 // @description 让牛牛聊天支持发送图片,发送任何外链图片链接时,自动转换为图片,可以点击查看大图;支持粘贴图片自动上传 // @match https://www.milkywayidle.com/* // @match https://test.milkywayidle.com/* // @grant GM_addStyle // @grant GM_xmlhttpRequest // @license MIT // @downloadURL none // ==/UserScript== (function() { 'use strict'; const imageType = 1;//改成0则不显示图片,而是显示为[图片],手动点击即可查看 const imageDefaultText = '[图片]';//会显示为[图片],也可以改成[image]等,按需,需要imageType改成0才生效 GM_addStyle(` .chat-img{display:inline-block} .chat-img img{display:inline-flex;margin:1px 4px;max-height:60px;max-width:100px;width:fit-content;border:2px solid #778be1;border-radius:4px;padding:1px;white-space:nowrap;background:#000;cursor:pointer} .chat-img span{padding:0 1px;border:0;margin:0;background:unset} .chat-img-preview{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);display:flex;justify-content:center;align-items:center;z-index:9999;cursor:zoom-out} .chat-img-preview img{max-width:90%;max-height:90%;border:2px solid #fff;border-radius:4px} .upload-status{position:fixed;bottom:20px;right:20px;padding:10px 15px;background:#4caf50;color:#fff;border-radius:4px;z-index:10000;box-shadow:0 2px 10px rgba(0,0,0,.2)} .emoji-btn{width:28px;height:28px;display:flex;justify-content:center;align-items:center;cursor:pointer;position:relative;border-radius:4px;padding:4px;background-color:var(--color-midnight-500);margin:2px} .emoji-btn:hover{background-color:var(--color-midnight-300)} .emoji-panel{display:none;position:absolute;width:450px;background:#2d2d2d;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.3);z-index:10000;border:solid 2px var(--color-midnight-100);border-radius:4px;padding:12px;background-color:var(--color-midnight-900);box-shadow:rgba(0,0,0,.3) 2px 2px 10px 6px;color:var(--color-text-dark-mode)} .emoji-panel.show{display:block} .emoji-header{align-items:center;font-size:18px;font-weight:600;text-align:center;padding-bottom:10px} .emoji-tabbar{display:flex;flex-wrap:wrap} .emoji-tab{background:0 0;border:none;padding:5px 10px;border-radius:4px;cursor:pointer} .emoji-tab.active{background:var(--color-space-600)} .emoji-tab:hover{background:var(--color-midnight-300)} .emoji-content{padding-top:10px} .emoji-close{background:0 0;border:none;position:absolute;top:6px;right:6px;height:22px;width:22px;padding:4px;cursor:pointer} .emoji-grid{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;padding:12px;background-color:var(--color-midnight-700);border-radius:4px;max-height:300px;overflow-y:scroll} .emoji-item{cursor:pointer;padding:4px;border-radius:4px;transition:background .2s} .emoji-item:hover{background:#3d3d3d} .emoji-item img{width:100%;height:auto} `); const chatHistorySelector = 'div.ChatHistory_chatHistory__1EiG3'; const chatMessageSelector = 'div.ChatMessage_chatMessage__2wev4'; const chatInputSelector = '.Chat_chatInput__16dhX'; const historyObservers = new WeakMap(); let inputObserver = null; let globalObserver; const handledInputs = new WeakSet(); let isProcessing = false; let emojiBtnObserver; let emojiPanel; const COMPRESSED_EMOJI_DATA = [ ["Adela", "2025/05/13", [ "6822787e1f9a3", "682278801af4c", "68227876259d7", "68227880e8b5b", "682278829c625", "682278b51e4ce" ]], ["Adriana", "2025/05/13", [ "682278dc64bc8", "682278df2aec2", "682278df9f902", "682278dec78c1", "682278e19aeef", "682278e57cc98", "682278e80ef54", "68227924b3820", "682279269379c", "6822792ad0801", "68227927a2726", "6822792e99f9d", "6822792495782" ]], ["Aiden", "2025/05/13", [ "682279ee7d34a", "682279e92545b", "682279ef77d86", "682279fbc0d07", "682279e9083d6", "682279f2098d4", "682279fbbcad6" ]], ["Alex", "2025/05/13", [ "68227a8437342", "68227a836aedc", "68227a7e3b1e4", "68227a85b5b3b", "68227a89a93e4", "68227a7e4090e", "68227ac730057", "68227aca492d5", "68227ac87c6a9", "68227acc230e7", "68227ad188f42", "68227ac961e61" ]], ["Angelika", "2025/05/13", [ "68227b12b2d6b", "68227b08f0c8f", "68227b09592c0", "68227b1146ce5", "68227b101397b", "68227b0a74f09" ]], ["Arda", "2025/05/13", [ "68227b5a04178", "68227b42d179b", "68227b42dc9ee", "68227b43504c5", "68227b43aa87c", "68227b4d2723d" ]], ["Aya", "2025/05/13", [ "68227bae53837", "68227bb199b76", "68227baf9e9f6", "68227bbe4ca1e", "68227bc80c410", "68227bae947b1", "68227baf17e05", "68227bed10bad", "68227bef7fbe2", "68227bf144282", "68227befad25f", "68227bf5c9372", "68227bedf39e0" ]], ["Azuko", "2025/05/13", [ "68227c26ec0fe", "68227c26e940c", "68227c28409ae", "68227c2b464a5", "68227c309dca2", "68227c26ed151" ]], ["Barbara", "2025/05/13", [ "68227c61bf4c6", "68227c6237b22", "68227c57ebdfd", "68227c5d4423b", "68227c591a910", "68227c59b5200", "68227c5c26676" ]], ["Bernice", "2025/05/13", [ "68227ca1a7788", "68227c981d78b", "68227c9b7ac7c", "68227c9886e21", "68227c9c8be28", "68227c992289b" ]], ["Bianca", "2025/05/13", [ "6822858c83f7e", "6822856d2124a", "68228560c0411", "6822856bed04b", "6822857080c78", "6822856132aa0" ]], ["Camilo", "2025/05/13", [ "682285dda8f27", "682285ee5344b", "682285f009ce2", "682285f1550dc", "682285c91b753", "682285c5e5d85", "68228617e9e7c", "6822861e34e07", "6822861964d77", "6822861aa750e", "6822861f903f5", "6822862391bd6" ]] ]; function decompressEmojiData() { const baseUrl = "https://tupian.li/images/"; return COMPRESSED_EMOJI_DATA.map(([name, date, files]) => ({ name, list: files.map(file => `${baseUrl}${date}/${file}.png`) })); } const emojiData = decompressEmojiData(); function isImageUrl(url) {// 检查链接是否是图片 return url && /\.(jpg|jpeg|png|gif|webp|bmp|svg)(\?.*)?$/i.test(url); } function createPreviewOverlay(imgSrc) {//创建预览 const overlay = document.createElement('div'); overlay.className = 'chat-img-preview'; const previewImg = document.createElement('img'); previewImg.src = imgSrc; overlay.appendChild(previewImg); document.body.appendChild(overlay); overlay.addEventListener('click', (e) => {// 点击后关闭图片预览 if (e.target === overlay || e.target === previewImg) { document.body.removeChild(overlay); } }); document.addEventListener('keydown', function handleEsc(e) {// ESC关闭图片预览 if (e.key === 'Escape') { document.body.removeChild(overlay); document.removeEventListener('keydown', handleEsc); } }); } function createPreviewableLink(url, altText) {//创建可预览的链接 const link = document.createElement('a'); link.href = url; link.target = '_blank'; link.rel = 'noreferrer noopener nofollow'; link.className = 'chat-img'; const img = document.createElement('img'); img.src = url; img.alt = altText; link.appendChild(img); link.addEventListener('click', function(e) { if (e.ctrlKey || e.metaKey) return; // 允许Ctrl+点击在新标签打开 e.preventDefault(); e.stopImmediatePropagation(); createPreviewOverlay(url); }); return link; } function replaceLinkContentWithImage(link) {//修改A标签内的图片 const href = link.getAttribute('href'); if (!isImageUrl(href)) return; if (link.querySelector('.chat-img')) return; const newLink = createPreviewableLink(href, '图片预览'); link.parentNode.replaceChild(newLink, link); } function convertEmojiCodes(container) { const walker = document.createTreeWalker( container, NodeFilter.SHOW_TEXT, { acceptNode: (node) => { if (node.parentNode.classList?.contains('processed-emoji')) { return NodeFilter.FILTER_REJECT; } return /{::\d+_\d+}/.test(node.nodeValue) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP; } }, false ); let node; while ((node = walker.nextNode())) { const fragment = document.createDocumentFragment(); const parts = node.nodeValue.split(/({::\d+_\d+})/); parts.forEach(part => { if (!part) return; const emojiMatch = part.match(/{::(\d+)_(\d+)}/); if (emojiMatch) { const groupIndex = parseInt(emojiMatch[1]) - 1; const emojiIndex = parseInt(emojiMatch[2]) - 1; // 安全检查 if (emojiData[groupIndex]?.list[emojiIndex]) { const url = emojiData[groupIndex].list[emojiIndex]; const link = createPreviewableLink(url, `emoji:${groupIndex+1}_${emojiIndex+1}`); fragment.appendChild(link); return; } } fragment.appendChild(document.createTextNode(part)); }); if (node.parentNode) { const wrapper = document.createElement('span'); wrapper.className = 'processed-emoji'; wrapper.appendChild(fragment); node.parentNode.replaceChild(wrapper, node); } } } function processExistingMessages(container) {//聊天页面消息处理 const messages = container.querySelectorAll(chatMessageSelector); messages.forEach(msg => { const links = msg.querySelectorAll('a'); links.forEach(replaceLinkContentWithImage); convertEmojiCodes(msg); }); } function observeChatHistory(chatHistory) {//监听聊天页面变化 processExistingMessages(chatHistory); const observer = new MutationObserver(mutations => { mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { const messages = node.matches(chatMessageSelector) ? [node] : node .querySelectorAll(chatMessageSelector); messages.forEach(msg => { const links = msg.querySelectorAll('a'); links.forEach(replaceLinkContentWithImage); }); } }); }); }); observer.observe(chatHistory, { childList: true, subtree: true }); } function initClipboardUpload() { if (inputObserver && typeof inputObserver.disconnect === 'function') { inputObserver.disconnect(); } const chatInput = document.querySelector(chatInputSelector); if (chatInput && !handledInputs.has(chatInput)) { setupPasteHandler(chatInput); return; } inputObserver = new MutationObserver((mutations) => { initEmojiPanel(); mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const input = node.matches(chatInputSelector) ? node : node.querySelector(chatInputSelector); if (input && !handledInputs.has(input)) { setupPasteHandler(input); } } }); }); }); inputObserver.observe(document.body, { childList: true, subtree: true }); } function setupPasteHandler(inputElement) { handledInputs.add(inputElement); inputElement.removeEventListener('paste', handlePaste); inputElement.addEventListener('paste', handlePaste); let isProcessing = false; async function handlePaste(e) { if (isProcessing) { e.preventDefault(); return; } isProcessing = true; try { const items = e.clipboardData.items; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { e.preventDefault(); const blob = items[i].getAsFile(); if (blob) await uploadAndInsertImage(blob, inputElement); break; } } } finally { isProcessing = false; } } } function uploadAndInsertImage(blob, inputElement) {//上传图片 const statusDiv = document.createElement('div'); statusDiv.className = 'upload-status'; statusDiv.textContent = '正在上传图片...'; document.body.appendChild(statusDiv); const boundary = '----WebKitFormBoundary' + Math.random().toString(36).substring(2); const formParts = []; function appendFile(name, file) { formParts.push(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"; filename="${file.name}"\r\nContent-Type: ${file.type}\r\n\r\n`); formParts.push(file); formParts.push('\r\n'); } appendFile('file', blob); formParts.push(`--${boundary}--\r\n`); const bodyBlob = new Blob(formParts); GM_xmlhttpRequest({ method: 'POST', url: 'https://tupian.li/api/v1/upload', data: bodyBlob, headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Accept': 'application/json' }, binary: true, onload: function(response) { statusDiv.remove(); if (response.status === 200) { try { const result = JSON.parse(response.responseText); if (result.status) { const url = result.data.links.url; const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value' ).set; const currentValue = inputElement.value; const newValue = currentValue ? `${currentValue} ${url}` : url; nativeInputValueSetter.call(inputElement, newValue); inputElement.dispatchEvent(new Event('input', { bubbles: true })); inputElement.focus(); const successDiv = document.createElement('div'); successDiv.className = 'upload-status'; successDiv.textContent = '上传成功!'; document.body.appendChild(successDiv); setTimeout(() => successDiv.remove(), 2000); } else { throw new Error(result.message || '上传失败'); } } catch (e) { showError('解析失败: ' + e.message); } } else { showError('服务器错误: ' + response.status); } }, onerror: function(error) { statusDiv.remove(); showError('上传失败: ' + error.statusText); } }); function showError(message) { const errorDiv = document.createElement('div'); errorDiv.className = 'upload-status error'; errorDiv.textContent = message; document.body.appendChild(errorDiv); setTimeout(() => errorDiv.remove(), 3000); console.error(message); } } function insertAtCursor(inputElement, text) {//插入文本,兼容SB VUE const start = inputElement.selectionStart; const end = inputElement.selectionEnd; const nativeInputValueSetter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, "value" ).set; nativeInputValueSetter.call(inputElement,inputElement.value.substring(0, start) + text + inputElement.value.substring(end) ); const event = new Event('input', { bubbles: true, cancelable: true }); inputElement.dispatchEvent(event); inputElement.selectionStart = inputElement.selectionEnd = start + text.length; inputElement.focus(); } function showStatus(message, duration = 3000, isError = false) {//上传状态 const existingStatus = document.querySelector('.upload-status'); if (existingStatus) existingStatus.remove(); const statusDiv = document.createElement('div'); statusDiv.className = 'upload-status'; statusDiv.textContent = message; statusDiv.style.background = isError ? '#F44336' : '#4CAF50'; document.body.appendChild(statusDiv); setTimeout(() => { statusDiv.remove(); }, duration); } function setupHistoryObserver(historyElement) {//设置聊天记录监听 if (historyObservers.has(historyElement)) { historyObservers.get(historyElement).disconnect(); } const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const messages = node.matches(chatMessageSelector) ? [node] : node.querySelectorAll(chatMessageSelector); messages.forEach((msg) => { const links = msg.querySelectorAll('a'); links.forEach(replaceLinkContentWithImage); convertEmojiCodes(msg); }); } }); }); }); observer.observe(historyElement, { childList: true, subtree: true }); historyObservers.set(historyElement, observer); const messages = historyElement.querySelectorAll(chatMessageSelector); messages.forEach((msg) => { const links = msg.querySelectorAll('a'); links.forEach(replaceLinkContentWithImage); }); } function setupGlobalObserver() {//全局监听 globalObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === Node.ELEMENT_NODE) { const newHistories = node.matches(chatHistorySelector) ? [node] : node.querySelectorAll(chatHistorySelector); newHistories.forEach(setupHistoryObserver); } }); }); }); globalObserver.observe(document.body, { childList: true, subtree: true }); } function initEmojiPanel() { const chatInput = document.querySelector(chatInputSelector); if (!chatInput || chatInput.previousElementSibling?.classList.contains('emoji-btn')) { return; } const emojiBtn = document.createElement('div'); emojiBtn.className = 'emoji-btn'; emojiBtn.innerHTML = ''; chatInput.parentNode.insertBefore(emojiBtn, chatInput); const panelContainer = document.createElement('div'); panelContainer.innerHTML = `
选择表情
${createEmojiPanelHTML()}
`; document.body.appendChild(panelContainer); emojiPanel = document.querySelector('.emoji-panel'); if (!emojiPanel) return; emojiBtn.addEventListener('click', (e) => {//打开表情按钮 e.stopPropagation(); emojiPanel.classList.toggle('show'); const btnRect = emojiBtn.getBoundingClientRect(); emojiPanel.style.bottom = `${window.innerHeight - btnRect.top + 3}px`; emojiPanel.style.left = `${btnRect.left}px`; emojiPanel.style.width = `${document.querySelector('.Chat_chatInputContainer__2euR8').offsetWidth - 4}px` }); const closeBtn = emojiPanel.querySelector('.emoji-close');//关闭表情面板 if (closeBtn) { closeBtn.addEventListener('click', (e) => { e.stopPropagation(); emojiPanel.classList.remove('show'); }); } emojiPanel.querySelectorAll('.emoji-tab').forEach(tab => { tab.addEventListener('click', () => { const groupIndex = parseInt(tab.dataset.group); if (isNaN(groupIndex)) return; emojiPanel.querySelector('.emoji-content').innerHTML = createEmojiGroupHTML(groupIndex); emojiPanel.querySelectorAll('.emoji-tab').forEach(t => t.classList.remove('active')); tab.classList.add('active'); }); }); if(!emojiPanel._hasEmojiListener) {//表情按钮 修复一个重复执行的BUG emojiPanel.addEventListener('click', (e) => { const emojiItem = e.target.closest('.emoji-item'); if (!emojiItem) return; e.stopPropagation(); e.stopImmediatePropagation(); const chatInput = document.querySelector('.Chat_chatInput__16dhX'); if (chatInput) { const groupId = emojiItem.dataset.group; const emojiId = emojiItem.dataset.emoji; insertAtCursor(chatInput, `{::${groupId}_${emojiId}}`); } emojiPanel.classList.remove('show'); }); emojiPanel._hasEmojiListener = true; } document.addEventListener('click', (e) => {//关闭面板 if (!emojiPanel.contains(e.target) && e.target !== emojiBtn) { emojiPanel.classList.remove('show'); } }); return panelContainer; } function createEmojiPanelHTML() { return `
${emojiData.map((group, index) => ` `).join('')}
${createEmojiGroupHTML(0)}
`; } function createEmojiGroupHTML(groupIndex) { const group = emojiData[groupIndex]; if (!group) return ''; return `
${group.list.map((url, emojiIndex) => `
{::${groupIndex + 1}_${emojiIndex + 1}}
`).join('')}
`; } function setupEmojiPanel() { document.querySelectorAll('.emoji-item').forEach(item => { item.addEventListener('click', () => { const key = item.dataset.key; const input = document.querySelector(chatInputSelector); if (input) { insertAtCursor(input, `{::${key}}`); document.querySelector('.emoji-panel')?.classList.remove('show'); } }); }); } function initEmojiButtonSystem() { emojiBtnObserver?.disconnect(); tryAddEmojiButton(); emojiBtnObserver = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.addedNodes.length) { tryAddEmojiButton(); } }); }); emojiBtnObserver.observe(document.body, { childList: true, subtree: true }); } function tryAddEmojiButton() { const chatInput = document.querySelector(chatInputSelector); if (!chatInput || chatInput.previousElementSibling?.classList.contains('emoji-btn')) { return; } if (!document.querySelector('.emoji-panel')) { initEmojiPanel(); } } // ---------- 插件,启动! ---------- function init() { document.querySelectorAll(chatHistorySelector).forEach(setupHistoryObserver); const chatHistories = document.querySelectorAll(chatHistorySelector); if (chatHistories.length === 0) { setTimeout(init, 1000); return; } chatHistories.forEach(observeChatHistory); const globalObserver = new MutationObserver(mutations => { initEmojiButtonSystem() mutations.forEach(mutation => { mutation.addedNodes.forEach(node => { if (node.nodeType === Node.ELEMENT_NODE) { const newHistories = node.querySelectorAll?.(chatHistorySelector) || []; newHistories.forEach(observeChatHistory); } }); }); }); globalObserver.observe(document.body, { childList: true, subtree: true }); } window.addEventListener('unload', () => { globalObserver?.disconnect(); historyObservers.forEach(obs => obs.disconnect()); }); init(); initClipboardUpload(); setupGlobalObserver(); initEmojiButtonSystem(); if (!emojiPanel) { emojiPanel = initEmojiPanel(); } })();