Greasy Fork

Greasy Fork is available in English.

牛牛聊天发图片插件

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

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();