V2EX Image Uploader

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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();
  }
})();