// ==UserScript==
// @name V2EX Image Uploader
// @namespace http://tampermonkey.net/1436051
// @version 1.0
// @description 在 V2EX 评论区快速上传图片并插入链接
// @author Dogxi
// @match https://www.v2ex.com/t/*
// @match https://v2ex.com/t/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=v2ex.com
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @connect api.imgur.com
// @license MIT
// ==/UserScript==
(function() {
'use strict';
const IMGUR_CLIENT_ID_KEY = 'imgurClientId';
let CLIENT_ID = GM_getValue(IMGUR_CLIENT_ID_KEY, null);
const STYLE = `
.imgur-upload-btn {
background: none;
border: none;
color: #778087;
cursor: pointer;
font-size: 13px;
padding: 0;
margin-left: 15px;
text-decoration: none;
transition: color 0.2s ease;
}
.imgur-upload-btn:hover {
color: #4d5256;
text-decoration: underline;
}
.hidden {
display: none !important;
}
.imgur-upload-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 9999;
}
.imgur-upload-modal-content {
background-color: #fff;
padding: 20px;
border-radius: 3px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
max-width: 450px;
width: 90%;
position: relative;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
}
.imgur-upload-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid #e2e2e2;
}
.imgur-upload-modal-title {
font-size: 15px;
font-weight: normal;
color: #000;
}
.imgur-upload-modal-close {
cursor: pointer;
font-size: 18px;
color: #ccc;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: color 0.2s ease;
}
.imgur-upload-modal-close:hover {
color: #999;
}
.imgur-upload-dropzone {
border: 1px dashed #ccc;
padding: 25px;
text-align: center;
margin-bottom: 15px;
cursor: pointer;
border-radius: 3px;
transition: border-color 0.2s ease;
font-size: 13px;
color: #666;
}
.imgur-upload-dropzone:hover {
border-color: #999;
}
.imgur-upload-dropzone.dragover {
border-color: #778087;
background-color: #f9f9f9;
}
.imgur-upload-preview {
margin-top: 10px;
max-width: 100%;
max-height: 150px;
border-radius: 2px;
}
.imgur-upload-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 15px;
padding-top: 10px;
border-top: 1px solid #e2e2e2;
}
.imgur-upload-config-btn {
background: none;
border: none;
color: #778087;
cursor: pointer;
font-size: 12px;
padding: 0;
}
.imgur-upload-config-btn:hover {
color: #4d5256;
text-decoration: underline;
}
.imgur-upload-submit-btn {
background-color: #f5f5f5;
border: 1px solid #ccc;
border-radius: 3px;
color: #333;
cursor: pointer;
font-size: 12px;
padding: 6px 12px;
transition: all 0.2s ease;
}
.imgur-upload-submit-btn:hover {
background-color: #e8e8e8;
}
.imgur-upload-submit-btn:disabled {
background-color: #f9f9f9;
color: #ccc;
cursor: not-allowed;
}
.imgur-upload-config-panel {
margin-top: 10px;
padding: 10px;
background-color: #f9f9f9;
border-radius: 3px;
border: 1px solid #e2e2e2;
}
.imgur-upload-config-row {
display: flex;
align-items: center;
margin-bottom: 8px;
}
.imgur-upload-config-row:last-child {
margin-bottom: 0;
}
.imgur-upload-config-label {
font-size: 12px;
color: #666;
width: 70px;
flex-shrink: 0;
}
.imgur-upload-config-input {
flex: 1;
padding: 3px 6px;
border: 1px solid #ccc;
border-radius: 2px;
font-size: 12px;
}
.imgur-upload-config-save {
background-color: #f5f5f5;
border: 1px solid #ccc;
border-radius: 2px;
color: #333;
cursor: pointer;
font-size: 11px;
margin-left: 6px;
padding: 3px 8px;
}
.imgur-upload-config-save:hover {
background-color: #e8e8e8;
}
.imgur-upload-modal-status {
color: #666;
font-size: 12px;
text-align: center;
}
.imgur-upload-modal-status.success {
color: #5cb85c;
}
.imgur-upload-modal-status.error {
color: #d9534f;
}
`;
// 添加样式到页面
function addStyle() {
const styleElement = document.createElement('style');
styleElement.textContent = STYLE;
document.head.appendChild(styleElement);
}
// 创建上传弹窗
function createUploadModal(textareaElement) {
const modal = document.createElement('div');
modal.className = 'imgur-upload-modal';
const content = document.createElement('div');
content.className = 'imgur-upload-modal-content';
content.innerHTML = `
<div class="imgur-upload-modal-header">
<div class="imgur-upload-modal-title">上传图片</div>
<div class="imgur-upload-modal-close">×</div>
</div>
<div class="imgur-upload-dropzone">
<div>点击选择图片或拖拽图片到此处</div>
<div style="font-size: 11px; color: #999; margin-top: 5px;">支持 JPG, PNG, GIF 格式</div>
</div>
<div class="imgur-upload-actions">
<button class="imgur-upload-config-btn">⚙️ 配置</button>
<button class="imgur-upload-submit-btn" disabled>确认上传</button>
</div>
<div class="imgur-upload-config-panel hidden">
<div class="imgur-upload-config-row">
<div class="imgur-upload-config-label">Imgur ID:</div>
<input type="text" class="imgur-upload-config-input" placeholder="请输入 Imgur Client ID" value="${CLIENT_ID || ''}">
<button class="imgur-upload-config-save">保存</button>
</div>
<div style="font-size: 11px; color: #666; margin-top: 8px;">
在 <a href="https://api.imgur.com/oauth2/addclient" target="_blank">https://api.imgur.com/oauth2/addclient</a> 注册获取(无回调)
</div>
</div>
`;
modal.appendChild(content);
document.body.appendChild(modal);
setupModalEvents(modal, textareaElement);
return modal;
}
// 设置弹窗事件监听
function setupModalEvents(modal, textareaElement) {
const closeBtn = modal.querySelector('.imgur-upload-modal-close');
const dropzone = modal.querySelector('.imgur-upload-dropzone');
const configBtn = modal.querySelector('.imgur-upload-config-btn');
const configPanel = modal.querySelector('.imgur-upload-config-panel');
const configInput = modal.querySelector('.imgur-upload-config-input');
const configSave = modal.querySelector('.imgur-upload-config-save');
const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
let selectedFile = null;
function closeModal() {
document.body.removeChild(modal);
}
closeBtn.addEventListener('click', closeModal);
modal.addEventListener('click', function(e) {
if (e.target === modal) closeModal();
});
configBtn.addEventListener('click', function() {
configPanel.classList.toggle('hidden');
});
configSave.addEventListener('click', function() {
const newClientId = configInput.value.trim();
if (newClientId) {
GM_setValue(IMGUR_CLIENT_ID_KEY, newClientId);
CLIENT_ID = newClientId;
configPanel.classList.add('hidden');
showStatusInModal(modal, '配置已保存', 'success');
}
});
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = 'image/*';
fileInput.style.display = 'none';
modal.appendChild(fileInput);
dropzone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', function(e) {
handleFileSelect(e.target.files[0]);
});
dropzone.addEventListener('dragover', function(e) {
e.preventDefault();
dropzone.classList.add('dragover');
});
dropzone.addEventListener('dragleave', function(e) {
e.preventDefault();
dropzone.classList.remove('dragover');
});
dropzone.addEventListener('drop', function(e) {
e.preventDefault();
dropzone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileSelect(files[0]);
}
});
// 处理文件选择
function handleFileSelect(file) {
if (!file || !file.type.match(/image\/.*/)) {
showStatusInModal(modal, '请选择图片文件', 'error');
return;
}
selectedFile = file;
const reader = new FileReader();
reader.onload = function(e) {
const preview = modal.querySelector('.imgur-upload-preview');
if (preview) preview.remove();
const img = document.createElement('img');
img.src = e.target.result;
img.className = 'imgur-upload-preview';
dropzone.appendChild(img);
submitBtn.disabled = false;
dropzone.querySelector('div').textContent = '已选择: ' + file.name;
};
reader.readAsDataURL(file);
}
submitBtn.addEventListener('click', function() {
if (!selectedFile) return;
if (!CLIENT_ID) {
showStatusInModal(modal, '请先配置 Imgur Client ID', 'error');
configPanel.classList.remove('hidden');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = '上传中...';
uploadToImgur(selectedFile, textareaElement, modal);
});
}
// 在弹窗中显示状态信息
function showStatusInModal(modal, message, type) {
let statusEl = modal.querySelector('.imgur-upload-modal-status');
if (!statusEl) {
statusEl = document.createElement('div');
statusEl.className = 'imgur-upload-modal-status';
statusEl.style.cssText = 'margin-top: 10px; font-size: 12px; text-align: center;';
modal.querySelector('.imgur-upload-modal-content').appendChild(statusEl);
}
statusEl.textContent = message;
statusEl.className = 'imgur-upload-modal-status ' + (type || '');
if (type === 'success') {
setTimeout(() => statusEl.textContent = '', 3000);
}
}
// 上传图片到 Imgur
function uploadToImgur(file, textareaElement, modal) {
if (!file.type.match(/image\/.*/)) {
showStatusInModal(modal, '请选择图片文件', 'error');
const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
submitBtn.disabled = false;
submitBtn.textContent = '确认上传';
return;
}
const formData = new FormData();
formData.append('image', file);
GM_xmlhttpRequest({
method: "POST",
url: "https://api.imgur.com/3/image",
headers: {
"Authorization": "Client-ID " + CLIENT_ID
},
data: formData,
responseType: "json",
onload: function(response) {
const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
try {
let responseData;
if (typeof response.response === 'string') {
responseData = JSON.parse(response.response);
} else {
responseData = response.response;
}
if (response.status === 200 && responseData && responseData.success) {
const imageUrl = responseData.data.link;
insertLinkIntoTextarea(textareaElement, imageUrl, file.name);
showStatusInModal(modal, '上传成功!', 'success');
setTimeout(() => {
document.body.removeChild(modal);
}, 1500);
} else {
let errorMessage = '';
if (response.status === 400) {
if (responseData && responseData.data && responseData.data.error) {
if (responseData.data.error === 'These actions are forbidden.') {
errorMessage = 'Client ID 无效或已被禁用,请检查配置';
} else {
errorMessage = responseData.data.error;
}
} else {
errorMessage = 'Client ID 配置错误';
}
} else if (response.status === 403) {
errorMessage = '访问被拒绝,请检查 Client ID 权限';
} else if (response.status === 429) {
errorMessage = '请求过于频繁,请稍后再试';
} else {
errorMessage = `上传失败 (${response.status})`;
}
console.error('Imgur 上传错误:', response);
showStatusInModal(modal, errorMessage, 'error');
if (response.status === 400 || response.status === 403) {
const configPanel = modal.querySelector('.imgur-upload-config-panel');
configPanel.classList.remove('hidden');
}
submitBtn.disabled = false;
submitBtn.textContent = '确认上传';
}
} catch (e) {
console.error('解析响应失败:', e, response);
showStatusInModal(modal, '响应解析失败,请重试', 'error');
submitBtn.disabled = false;
submitBtn.textContent = '确认上传';
}
},
onerror: function(error) {
console.error('GM_xmlhttpRequest 错误:', error);
showStatusInModal(modal, '网络请求失败,请检查连接', 'error');
const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
submitBtn.disabled = false;
submitBtn.textContent = '确认上传';
},
ontimeout: function() {
console.error('Imgur 上传超时');
showStatusInModal(modal, '上传超时,请重试', 'error');
const submitBtn = modal.querySelector('.imgur-upload-submit-btn');
submitBtn.disabled = false;
submitBtn.textContent = '确认上传';
}
});
}
// 将图片链接插入到文本框
function insertLinkIntoTextarea(textareaElement, imageUrl, fileName) {
const altText = fileName ? fileName.split('.')[0] : 'image';
const textToInsert = imageUrl;
const currentValue = textareaElement.value;
const selectionStart = textareaElement.selectionStart;
const selectionEnd = textareaElement.selectionEnd;
const newText = currentValue.substring(0, selectionStart) + textToInsert + currentValue.substring(selectionEnd);
textareaElement.value = newText;
const newCursorPosition = selectionStart + textToInsert.length;
textareaElement.selectionStart = newCursorPosition;
textareaElement.selectionEnd = newCursorPosition;
textareaElement.focus();
textareaElement.dispatchEvent(new Event('input', { bubbles: true, cancelable: true }));
}
// 在页面头部添加上传按钮
function addUploadButtonToHeader() {
const replyBox = document.getElementById('reply-box');
if (!replyBox) return;
const headerCell = replyBox.querySelector('.cell.flex-one-row');
if (!headerCell) return;
if (headerCell.querySelector('.imgur-upload-btn')) return;
const leftDiv = headerCell.querySelector('div:first-child');
if (leftDiv) {
const uploadBtn = document.createElement('a');
uploadBtn.className = 'imgur-upload-btn';
uploadBtn.textContent = '上传';
uploadBtn.href = 'javascript:void(0);';
uploadBtn.title = '上传图片';
uploadBtn.style.marginLeft = '10px';
leftDiv.appendChild(uploadBtn);
uploadBtn.addEventListener('click', function(e) {
e.preventDefault();
const textarea = document.getElementById('reply_content');
if (textarea) {
createUploadModal(textarea);
}
});
}
}
// 查找并添加上传按钮
function findTextareasAndAddButtons() {
addUploadButtonToHeader();
setupMutationObserver();
}
// 监听DOM变化
function setupMutationObserver() {
const observer = new MutationObserver(function(mutations) {
let shouldCheck = false;
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE &&
(node.id === 'reply-box' || node.querySelector('#reply-box'))) {
shouldCheck = true;
}
});
});
if (shouldCheck) {
setTimeout(addUploadButtonToHeader, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// 初始化脚本
function init() {
addStyle();
setTimeout(findTextareasAndAddButtons, 100);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();