V2EX Image Uploader

在 V2EX 评论区快速上传图片并插入链接

  1. // ==UserScript==
  2. // @name V2EX Image Uploader
  3. // @version 1.1
  4. // @description 在 V2EX 评论区快速上传图片并插入链接
  5. // @author Dogxi
  6. // @match https://www.v2ex.com/t/*
  7. // @match https://v2ex.com/t/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=v2ex.com
  9. // @grant GM_xmlhttpRequest
  10. // @grant GM_setValue
  11. // @grant GM_getValue
  12. // @connect api.imgur.com
  13. // @license MIT
  14. // @namespace http://tampermonkey.net/1436051
  15. // ==/UserScript==
  16.  
  17. (function () {
  18. "use strict";
  19.  
  20. const IMGUR_CLIENT_ID_KEY = "imgurClientId";
  21. let CLIENT_ID = GM_getValue(IMGUR_CLIENT_ID_KEY, null);
  22.  
  23. const STYLE = `
  24. .imgur-upload-btn {
  25. background: none;
  26. border: none;
  27. color: #778087;
  28. cursor: pointer;
  29. font-size: 13px;
  30. padding: 0;
  31. margin-left: 15px;
  32. text-decoration: none;
  33. transition: color 0.2s ease;
  34. }
  35. .imgur-upload-btn:hover {
  36. color: #4d5256;
  37. text-decoration: underline;
  38. }
  39. .hidden {
  40. display: none !important;
  41. }
  42. .imgur-upload-modal {
  43. position: fixed;
  44. top: 0;
  45. left: 0;
  46. width: 100%;
  47. height: 100%;
  48. background-color: rgba(0, 0, 0, 0.5);
  49. display: flex;
  50. justify-content: center;
  51. align-items: center;
  52. z-index: 9999;
  53. outline: none;
  54. }
  55. .imgur-upload-modal-content {
  56. background-color: #fff;
  57. padding: 20px;
  58. border-radius: 3px;
  59. box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
  60. max-width: 450px;
  61. width: 90%;
  62. position: relative;
  63. font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
  64. }
  65. .imgur-upload-modal-header {
  66. display: flex;
  67. justify-content: space-between;
  68. align-items: center;
  69. margin-bottom: 15px;
  70. padding-bottom: 10px;
  71. border-bottom: 1px solid #e2e2e2;
  72. }
  73. .imgur-upload-modal-title {
  74. font-size: 15px;
  75. font-weight: normal;
  76. color: #000;
  77. }
  78. .imgur-upload-modal-close {
  79. cursor: pointer;
  80. font-size: 18px;
  81. color: #ccc;
  82. width: 20px;
  83. height: 20px;
  84. display: flex;
  85. align-items: center;
  86. justify-content: center;
  87. transition: color 0.2s ease;
  88. }
  89. .imgur-upload-modal-close:hover {
  90. color: #999;
  91. }
  92. .imgur-upload-dropzone {
  93. border: 1px dashed #ccc;
  94. padding: 25px;
  95. text-align: center;
  96. margin-bottom: 15px;
  97. cursor: pointer;
  98. border-radius: 3px;
  99. transition: border-color 0.2s ease;
  100. font-size: 13px;
  101. color: #666;
  102. outline: none;
  103. }
  104. .imgur-upload-dropzone:hover {
  105. border-color: #999;
  106. }
  107. .imgur-upload-dropzone:focus {
  108. border-color: #778087;
  109. background-color: #f9f9f9;
  110. }
  111. .imgur-upload-dropzone.dragover {
  112. border-color: #778087;
  113. background-color: #f9f9f9;
  114. }
  115. .imgur-upload-preview {
  116. margin-top: 10px;
  117. max-width: 100%;
  118. max-height: 150px;
  119. border-radius: 2px;
  120. }
  121. .imgur-upload-actions {
  122. display: flex;
  123. justify-content: space-between;
  124. align-items: center;
  125. margin-top: 15px;
  126. padding-top: 10px;
  127. border-top: 1px solid #e2e2e2;
  128. }
  129. .imgur-upload-config-btn {
  130. background: none;
  131. border: none;
  132. color: #778087;
  133. cursor: pointer;
  134. font-size: 12px;
  135. padding: 0;
  136. }
  137. .imgur-upload-config-btn:hover {
  138. color: #4d5256;
  139. text-decoration: underline;
  140. }
  141. .imgur-upload-submit-btn {
  142. background-color: #f5f5f5;
  143. border: 1px solid #ccc;
  144. border-radius: 3px;
  145. color: #333;
  146. cursor: pointer;
  147. font-size: 12px;
  148. padding: 6px 12px;
  149. transition: all 0.2s ease;
  150. }
  151. .imgur-upload-submit-btn:hover {
  152. background-color: #e8e8e8;
  153. }
  154. .imgur-upload-submit-btn:disabled {
  155. background-color: #f9f9f9;
  156. color: #ccc;
  157. cursor: not-allowed;
  158. }
  159. .imgur-upload-config-panel {
  160. margin-top: 10px;
  161. padding: 10px;
  162. background-color: #f9f9f9;
  163. border-radius: 3px;
  164. border: 1px solid #e2e2e2;
  165. }
  166. .imgur-upload-config-row {
  167. display: flex;
  168. align-items: center;
  169. margin-bottom: 8px;
  170. }
  171. .imgur-upload-config-row:last-child {
  172. margin-bottom: 0;
  173. }
  174. .imgur-upload-config-label {
  175. font-size: 12px;
  176. color: #666;
  177. width: 70px;
  178. flex-shrink: 0;
  179. }
  180. .imgur-upload-config-input {
  181. flex: 1;
  182. padding: 3px 6px;
  183. border: 1px solid #ccc;
  184. border-radius: 2px;
  185. font-size: 12px;
  186. }
  187. .imgur-upload-config-save {
  188. background-color: #f5f5f5;
  189. border: 1px solid #ccc;
  190. border-radius: 2px;
  191. color: #333;
  192. cursor: pointer;
  193. font-size: 11px;
  194. margin-left: 6px;
  195. padding: 3px 8px;
  196. }
  197. .imgur-upload-config-save:hover {
  198. background-color: #e8e8e8;
  199. }
  200. .imgur-upload-modal-status {
  201. color: #666;
  202. font-size: 12px;
  203. text-align: center;
  204. }
  205. .imgur-upload-modal-status.success {
  206. color: #5cb85c;
  207. }
  208. .imgur-upload-modal-status.error {
  209. color: #d9534f;
  210. }
  211. `;
  212.  
  213. // 添加样式到页面
  214. function addStyle() {
  215. const styleElement = document.createElement("style");
  216. styleElement.textContent = STYLE;
  217. document.head.appendChild(styleElement);
  218. }
  219.  
  220. // 创建上传弹窗
  221. function createUploadModal(textareaElement) {
  222. const modal = document.createElement("div");
  223. modal.className = "imgur-upload-modal";
  224.  
  225. const content = document.createElement("div");
  226. content.className = "imgur-upload-modal-content";
  227.  
  228. content.innerHTML = `
  229. <div class="imgur-upload-modal-header">
  230. <div class="imgur-upload-modal-title">上传图片</div>
  231. <div class="imgur-upload-modal-close">×</div>
  232. </div>
  233. <div class="imgur-upload-dropzone">
  234. <div>点击选择图片、拖拽图片到此处,或直接粘贴图片</div>
  235. <div style="font-size: 11px; color: #999; margin-top: 5px;">支持 JPG, PNG, GIF 格式</div>
  236. </div>
  237. <div class="imgur-upload-actions">
  238. <button class="imgur-upload-config-btn">⚙️ 配置</button>
  239. <button class="imgur-upload-submit-btn" disabled>确认上传</button>
  240. </div>
  241. <div class="imgur-upload-config-panel hidden">
  242. <div class="imgur-upload-config-row">
  243. <div class="imgur-upload-config-label">Imgur ID:</div>
  244. <input type="text" class="imgur-upload-config-input" placeholder="请输入 Imgur Client ID" value="${
  245. CLIENT_ID || ""
  246. }">
  247. <button class="imgur-upload-config-save">保存</button>
  248. </div>
  249. <div style="font-size: 11px; color: #666; margin-top: 8px;">
  250. <a href="https://api.imgur.com/oauth2/addclient" target="_blank">https://api.imgur.com/oauth2/addclient</a> 注册(不可用)获取(无回调)
  251. </div>
  252. </div>
  253. `;
  254.  
  255. modal.appendChild(content);
  256. document.body.appendChild(modal);
  257.  
  258. setupModalEvents(modal, textareaElement);
  259.  
  260. return modal;
  261. }
  262.  
  263. // 设置弹窗事件监听
  264. function setupModalEvents(modal, textareaElement) {
  265. const closeBtn = modal.querySelector(".imgur-upload-modal-close");
  266. const dropzone = modal.querySelector(".imgur-upload-dropzone");
  267. const configBtn = modal.querySelector(".imgur-upload-config-btn");
  268. const configPanel = modal.querySelector(".imgur-upload-config-panel");
  269. const configInput = modal.querySelector(".imgur-upload-config-input");
  270. const configSave = modal.querySelector(".imgur-upload-config-save");
  271. const submitBtn = modal.querySelector(".imgur-upload-submit-btn");
  272.  
  273. let selectedFile = null;
  274.  
  275. function closeModal() {
  276. document.body.removeChild(modal);
  277. }
  278.  
  279. closeBtn.addEventListener("click", closeModal);
  280. modal.addEventListener("click", function (e) {
  281. if (e.target === modal) closeModal();
  282. });
  283.  
  284. configBtn.addEventListener("click", function () {
  285. configPanel.classList.toggle("hidden");
  286. });
  287.  
  288. configSave.addEventListener("click", function () {
  289. const newClientId = configInput.value.trim();
  290. if (newClientId) {
  291. GM_setValue(IMGUR_CLIENT_ID_KEY, newClientId);
  292. CLIENT_ID = newClientId;
  293. configPanel.classList.add("hidden");
  294. showStatusInModal(modal, "配置已保存", "success");
  295. }
  296. });
  297.  
  298. const fileInput = document.createElement("input");
  299. fileInput.type = "file";
  300. fileInput.accept = "image/*";
  301. fileInput.style.display = "none";
  302. modal.appendChild(fileInput);
  303.  
  304. dropzone.addEventListener("click", () => fileInput.click());
  305.  
  306. fileInput.addEventListener("change", function (e) {
  307. handleFileSelect(e.target.files[0]);
  308. });
  309.  
  310. dropzone.addEventListener("dragover", function (e) {
  311. e.preventDefault();
  312. dropzone.classList.add("dragover");
  313. });
  314.  
  315. dropzone.addEventListener("dragleave", function (e) {
  316. e.preventDefault();
  317. dropzone.classList.remove("dragover");
  318. });
  319.  
  320. dropzone.addEventListener("drop", function (e) {
  321. e.preventDefault();
  322. dropzone.classList.remove("dragover");
  323. const files = e.dataTransfer.files;
  324. if (files.length > 0) {
  325. handleFileSelect(files[0]);
  326. }
  327. });
  328.  
  329. // 在整个弹窗上监听粘贴事件
  330. modal.addEventListener("paste", function (e) {
  331. e.preventDefault();
  332. const items = e.clipboardData.items;
  333.  
  334. for (let i = 0; i < items.length; i++) {
  335. const item = items[i];
  336. if (item.type.indexOf("image") !== -1) {
  337. const file = item.getAsFile();
  338. if (file) {
  339. handleFileSelect(file);
  340. break;
  341. }
  342. }
  343. }
  344. });
  345.  
  346. // 让弹窗能够接收键盘事件
  347. modal.setAttribute("tabindex", "0");
  348. modal.focus();
  349.  
  350. // 处理文件选择
  351. function handleFileSelect(file) {
  352. if (!file || !file.type.match(/image\/.*/)) {
  353. showStatusInModal(modal, "请选择图片文件", "error");
  354. return;
  355. }
  356.  
  357. selectedFile = file;
  358.  
  359. const reader = new FileReader();
  360. reader.onload = function (e) {
  361. const preview = modal.querySelector(".imgur-upload-preview");
  362. if (preview) preview.remove();
  363.  
  364. const img = document.createElement("img");
  365. img.src = e.target.result;
  366. img.className = "imgur-upload-preview";
  367. dropzone.appendChild(img);
  368.  
  369. submitBtn.disabled = false;
  370. dropzone.querySelector("div").textContent = "已选择: " + file.name;
  371. };
  372. reader.readAsDataURL(file);
  373. }
  374.  
  375. submitBtn.addEventListener("click", function () {
  376. if (!selectedFile) return;
  377.  
  378. if (!CLIENT_ID) {
  379. showStatusInModal(modal, "请先配置 Imgur Client ID", "error");
  380. configPanel.classList.remove("hidden");
  381. return;
  382. }
  383.  
  384. submitBtn.disabled = true;
  385. submitBtn.textContent = "上传中...";
  386.  
  387. uploadToImgur(selectedFile, textareaElement, modal);
  388. });
  389. }
  390.  
  391. // 在弹窗中显示状态信息
  392. function showStatusInModal(modal, message, type) {
  393. let statusEl = modal.querySelector(".imgur-upload-modal-status");
  394. if (!statusEl) {
  395. statusEl = document.createElement("div");
  396. statusEl.className = "imgur-upload-modal-status";
  397. statusEl.style.cssText =
  398. "margin-top: 10px; font-size: 12px; text-align: center;";
  399. modal.querySelector(".imgur-upload-modal-content").appendChild(statusEl);
  400. }
  401.  
  402. statusEl.textContent = message;
  403. statusEl.className = "imgur-upload-modal-status " + (type || "");
  404.  
  405. if (type === "success") {
  406. setTimeout(() => (statusEl.textContent = ""), 3000);
  407. }
  408. }
  409.  
  410. // 上传图片到 Imgur
  411. function uploadToImgur(file, textareaElement, modal) {
  412. if (!file.type.match(/image\/.*/)) {
  413. showStatusInModal(modal, "请选择图片文件", "error");
  414. const submitBtn = modal.querySelector(".imgur-upload-submit-btn");
  415. submitBtn.disabled = false;
  416. submitBtn.textContent = "确认上传";
  417. return;
  418. }
  419.  
  420. const formData = new FormData();
  421. formData.append("image", file);
  422.  
  423. GM_xmlhttpRequest({
  424. method: "POST",
  425. url: "https://api.imgur.com/3/image",
  426. headers: {
  427. Authorization: "Client-ID " + CLIENT_ID,
  428. },
  429. data: formData,
  430. responseType: "json",
  431. onload: function (response) {
  432. const submitBtn = modal.querySelector(".imgur-upload-submit-btn");
  433.  
  434. try {
  435. let responseData;
  436. if (typeof response.response === "string") {
  437. responseData = JSON.parse(response.response);
  438. } else {
  439. responseData = response.response;
  440. }
  441.  
  442. if (response.status === 200 && responseData && responseData.success) {
  443. const imageUrl = responseData.data.link;
  444. insertLinkIntoTextarea(textareaElement, imageUrl, file.name);
  445. showStatusInModal(modal, "上传成功!", "success");
  446.  
  447. setTimeout(() => {
  448. document.body.removeChild(modal);
  449. }, 1500);
  450. } else {
  451. let errorMessage = "";
  452.  
  453. if (response.status === 400) {
  454. if (
  455. responseData &&
  456. responseData.data &&
  457. responseData.data.error
  458. ) {
  459. if (
  460. responseData.data.error === "These actions are forbidden."
  461. ) {
  462. errorMessage = "Client ID 无效或已被禁用,请检查配置";
  463. } else {
  464. errorMessage = responseData.data.error;
  465. }
  466. } else {
  467. errorMessage = "Client ID 配置错误";
  468. }
  469. } else if (response.status === 403) {
  470. errorMessage = "访问被拒绝,请检查 Client ID 权限";
  471. } else if (response.status === 429) {
  472. errorMessage = "请求过于频繁,请稍后再试";
  473. } else {
  474. errorMessage = `上传失败 (${response.status})`;
  475. }
  476.  
  477. console.error("Imgur 上传错误:", response);
  478. showStatusInModal(modal, errorMessage, "error");
  479.  
  480. if (response.status === 400 || response.status === 403) {
  481. const configPanel = modal.querySelector(
  482. ".imgur-upload-config-panel"
  483. );
  484. configPanel.classList.remove("hidden");
  485. }
  486.  
  487. submitBtn.disabled = false;
  488. submitBtn.textContent = "确认上传";
  489. }
  490. } catch (e) {
  491. console.error("解析响应失败:", e, response);
  492. showStatusInModal(modal, "响应解析失败,请重试", "error");
  493.  
  494. submitBtn.disabled = false;
  495. submitBtn.textContent = "确认上传";
  496. }
  497. },
  498. onerror: function (error) {
  499. console.error("GM_xmlhttpRequest 错误:", error);
  500. showStatusInModal(modal, "网络请求失败,请检查连接", "error");
  501.  
  502. const submitBtn = modal.querySelector(".imgur-upload-submit-btn");
  503. submitBtn.disabled = false;
  504. submitBtn.textContent = "确认上传";
  505. },
  506. ontimeout: function () {
  507. console.error("Imgur 上传超时");
  508. showStatusInModal(modal, "上传超时,请重试", "error");
  509.  
  510. const submitBtn = modal.querySelector(".imgur-upload-submit-btn");
  511. submitBtn.disabled = false;
  512. submitBtn.textContent = "确认上传";
  513. },
  514. });
  515. }
  516.  
  517. // 将图片链接插入到文本框
  518. function insertLinkIntoTextarea(textareaElement, imageUrl, fileName) {
  519. const altText = fileName ? fileName.split(".")[0] : "image";
  520. const textToInsert = imageUrl;
  521.  
  522. const currentValue = textareaElement.value;
  523. const selectionStart = textareaElement.selectionStart;
  524. const selectionEnd = textareaElement.selectionEnd;
  525.  
  526. const newText =
  527. currentValue.substring(0, selectionStart) +
  528. textToInsert +
  529. currentValue.substring(selectionEnd);
  530. textareaElement.value = newText;
  531.  
  532. const newCursorPosition = selectionStart + textToInsert.length;
  533. textareaElement.selectionStart = newCursorPosition;
  534. textareaElement.selectionEnd = newCursorPosition;
  535.  
  536. textareaElement.focus();
  537. textareaElement.dispatchEvent(
  538. new Event("input", { bubbles: true, cancelable: true })
  539. );
  540. }
  541.  
  542. // 在页面头部添加上传按钮
  543. function addUploadButtonToHeader() {
  544. const replyBox = document.getElementById("reply-box");
  545. if (!replyBox) return;
  546.  
  547. const headerCell = replyBox.querySelector(".cell.flex-one-row");
  548. if (!headerCell) return;
  549.  
  550. if (headerCell.querySelector(".imgur-upload-btn")) return;
  551.  
  552. const leftDiv = headerCell.querySelector("div:first-child");
  553. if (leftDiv) {
  554. const uploadBtn = document.createElement("a");
  555. uploadBtn.className = "imgur-upload-btn";
  556. uploadBtn.textContent = "上传";
  557. uploadBtn.href = "javascript:void(0);";
  558. uploadBtn.title = "上传图片";
  559. uploadBtn.style.marginLeft = "10px";
  560.  
  561. leftDiv.appendChild(uploadBtn);
  562.  
  563. uploadBtn.addEventListener("click", function (e) {
  564. e.preventDefault();
  565. const textarea = document.getElementById("reply_content");
  566. if (textarea) {
  567. createUploadModal(textarea);
  568. }
  569. });
  570. }
  571. }
  572.  
  573. // 查找并添加上传按钮
  574. function findTextareasAndAddButtons() {
  575. addUploadButtonToHeader();
  576. setupMutationObserver();
  577. }
  578.  
  579. // 监听DOM变化
  580. function setupMutationObserver() {
  581. const observer = new MutationObserver(function (mutations) {
  582. let shouldCheck = false;
  583. mutations.forEach(function (mutation) {
  584. mutation.addedNodes.forEach(function (node) {
  585. if (
  586. node.nodeType === Node.ELEMENT_NODE &&
  587. (node.id === "reply-box" || node.querySelector("#reply-box"))
  588. ) {
  589. shouldCheck = true;
  590. }
  591. });
  592. });
  593.  
  594. if (shouldCheck) {
  595. setTimeout(addUploadButtonToHeader, 100);
  596. }
  597. });
  598.  
  599. observer.observe(document.body, {
  600. childList: true,
  601. subtree: true,
  602. });
  603. }
  604.  
  605. // 初始化脚本
  606. function init() {
  607. addStyle();
  608. setTimeout(findTextareasAndAddButtons, 100);
  609. }
  610.  
  611. if (document.readyState === "loading") {
  612. document.addEventListener("DOMContentLoaded", init);
  613. } else {
  614. init();
  615. }
  616. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址