Greasy Fork

Greasy Fork is available in English.

牛牛聊天发图片插件

让牛牛聊天支持发送图片,发送任何外链图片链接时,自动转换为图片,可以点击查看大图;支持粘贴图片自动上传

当前为 2025-05-12 提交的版本,查看 最新版本

// ==UserScript==
// @name         牛牛聊天发图片插件
// @namespace    https://www.milkywayidle.com/
// @version      0.1.5
// @description  让牛牛聊天支持发送图片,发送任何外链图片链接时,自动转换为图片,可以点击查看大图;支持粘贴图片自动上传
// @match        https://www.milkywayidle.com/*
// @match        https://test.milkywayidle.com/*
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @license MIT
// ==/UserScript==

(function() {
	'use strict';
	const imageType = 1;//改成0则不显示图片,而是显示为[图片],手动点击即可查看
	const imageDefaultText = '[图片]';//会显示为[图片],也可以改成[image]等,按需,需要imageType改成0才生效
	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-text{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)}
    `);
	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;

	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 = '';
		if(imageType == 1){
			var img = document.createElement('img');
			img.src = href;
			img.className = 'chat-img';
		}else{
			var img = document.createElement('span');
			img.innerText = imageDefaultText;
			img.className = 'chat-img chat-img-text';
		}
		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() {
		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) => {
            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);
						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 => {
			initClipboardUpload()
			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();
})();