// ==UserScript==
// @name 牛牛聊天发图片插件
// @namespace https://www.milkywayidle.com/
// @version 0.1.4
// @description 让牛牛聊天支持发送图片,发送任何外链图片链接时,自动转换为图片,可以点击查看大图;支持粘贴图片自动上传
// @match https://www.milkywayidle.com/*
// @match https://test.milkywayidle.com/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @license MIT
// ==/UserScript==
(function() {
'use strict';
GM_addStyle(`
.chat-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-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)}
`);
const chatHistorySelector = 'div.ChatHistory_chatHistory__1EiG3';
const chatMessageSelector = 'div.ChatMessage_chatMessage__2wev4';
const historyObservers = new WeakMap();
let globalObserver;
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 replaceLinkContentWithImage(link) {//修改A标签内的图片
const href = link.getAttribute('href');
if (!isImageUrl(href)) return;
if (link.querySelector('.chat-img')) return;
link.innerHTML = '';
const img = document.createElement('img');
img.src = href;
img.className = 'chat-img';
link.appendChild(img);
const newLink = link.cloneNode(true);//移除所有现有的事件监听器,用于屏蔽聊天URL确认框(@guch8017 提交)
link.parentNode.replaceChild(newLink, link);
newLink.addEventListener('click', (e) => {//改为插件的点击处理
e.preventDefault();
e.stopImmediatePropagation();
createPreviewOverlay(href);
});
}
function processExistingMessages(container) {//聊天页面消息处理
const messages = container.querySelectorAll(chatMessageSelector);
messages.forEach(msg => {
const links = msg.querySelectorAll('a');
links.forEach(replaceLinkContentWithImage);
});
}
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() {
const chatInputSelector = '.Chat_chatInput__16dhX';
const chatInput = document.querySelector(chatInputSelector);
if (!chatInput) {
console.log('未找到输入框,延迟重试...');
setTimeout(initClipboardUpload, 1000);
return;
}
chatInput.addEventListener('paste', async (e) => {// 监听粘贴
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();
await uploadAndInsertImage(blob, chatInput);
return;
}
}
});
}
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);
console.log(result)
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) {//插入文本
const startPos = inputElement.selectionStart;
const endPos = inputElement.selectionEnd;
const currentText = inputElement.value;
inputElement.value = currentText.substring(0, startPos) + text + currentText.substring(endPos);
inputElement.selectionStart = inputElement.selectionEnd = startPos + 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);
});
}
});
});
});
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 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 => {
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();
})();