V2EX Image Uploader

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         V2EX Image Uploader
// @version      1.1.1
// @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
// @namespace http://tampermonkey.net/1436051
// ==/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;
              outline: none;
          }
          .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;
              outline: none;
          }
          .imgur-upload-dropzone:hover {
              border-color: #999;
          }
          .imgur-upload-dropzone:focus {
              border-color: #778087;
              background-color: #f9f9f9;
          }
          .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();
    });

    modal.addEventListener("keydown", function (e) {
      if (e.key === "Escape") {
        e.preventDefault();
        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]);
      }
    });

    // 在整个弹窗上监听粘贴事件
    modal.addEventListener("paste", function (e) {
      e.preventDefault();
      const items = e.clipboardData.items;

      for (let i = 0; i < items.length; i++) {
        const item = items[i];
        if (item.type.indexOf("image") !== -1) {
          const file = item.getAsFile();
          if (file) {
            handleFileSelect(file);
            break;
          }
        }
      }
    });

    // 让弹窗能够接收键盘事件
    modal.setAttribute("tabindex", "0");
    modal.focus();

    // 处理文件选择
    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);
    }
  }

  // 查找并添加上传按钮
  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();

    document.body.addEventListener("click", function (e) {
      const uploadBtn = e.target.closest(".imgur-upload-btn");
      if (uploadBtn) {
        e.preventDefault();
        const textarea = document.getElementById("reply_content");
        if (textarea) {
          createUploadModal(textarea);
        }
      }
    });

    setTimeout(findTextareasAndAddButtons, 100);
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();