Appinn Forum Upload Enhancer

小众软件论坛发帖或回复时,粘贴、拖曳或上传按钮选择图片/文件,自动上传到 h1.appinn.me 并转为对应的 Markdown 格式输出。

// ==UserScript==
// @name         Appinn Forum Upload Enhancer
// @name:zh-CN   小众软件论坛上传优化
// @license      AGPL-3.0
// @version      0.4.0
// @author       xymoryn
// @namespace    https://github.com/xymoryn
// @icon         https://h1.appinn.me/logo.png
// @description  小众软件论坛发帖或回复时,粘贴、拖曳或上传按钮选择图片/文件,自动上传到 h1.appinn.me 并转为对应的 Markdown 格式输出。
// @homepage     https://github.com/xymoryn/user-scripts
// @supportURL   https://github.com/xymoryn/user-scripts/issues
// @run-at       document-idle
// @match        https://meta.appinn.net/*
// @grant        GM_xmlhttpRequest
// ==/UserScript==

(function () {
  'use strict';

  /**
   * 全局配置对象
   * @type {Object}
   */
  const CONFIG = {
    /** 是否开启调试模式,控制控制台输出 */
    DEBUG: false,

    /** 文件大小限制 (20MB) */
    MAX_FILE_SIZE: 20 * 1024 * 1024,

    /** 上传端点 */
    UPLOAD_ENDPOINT: 'https://h1.appinn.me/upload',

    /**
     * 上传配置参数
     * @property {string} authCode - 认证码(必填)
     * @property {boolean} serverCompress - 是否启用 Telegram 图片压缩:
     *   - 启用丢失透明度
     *   - 对大于 10MB 的文件无效
     * @property {'telegram'|'cfr2'|'s3'} uploadChannel - 文件上传渠道:
     *   - 'telegram': Telegram。当前小众图床唯一可用的上传渠道。
     *   - 'cfr2': Cloudflare R2
     *   - 's3': Amazon S3
     * @property {'default'|'index'|'origin'|'short'} uploadNameType - 文件命名方式:
     *   - 'default': 时间戳_原始文件名
     *   - 'index': 仅时间戳
     *   - 'origin': 原始文件名
     *   - 'short': 类似短链接的随机字母数字
     * @property {boolean} autoRetry - 上传失败时是否自动切换到其他渠道
     */
    UPLOAD_PARAMS: {
      authCode: 'appinn2',
      serverCompress: false,
      uploadChannel: 'telegram',
      uploadNameType: 'default',
      autoRetry: true,
    },

    /** 资源访问 URL 前缀 */
    ASSETS_URL_PREFIX: 'https://h1.appinn.me',

    /**
     * 支持的文件类型
     * @type {Object.<string, {test: Function, format: Function, acceptString: string}>}
     */
    SUPPORTED_MIME_TYPES: {
      'image': {
        test: (type) => type.startsWith('image/'),
        format: (filename, url) => `![${filename}](${url})`,
        acceptString: 'image/*',
      },
      'video': {
        test: (type) => type.startsWith('video/'),
        format: (filename, url) => `![${filename}|video](${url})`,
        acceptString: 'video/*',
      },
      'audio': {
        test: (type) => type.startsWith('audio/'),
        format: (filename, url) => `![${filename}|audio](${url})`,
        acceptString: 'audio/*',
      },
      'pdf': {
        test: (type) => type === 'application/pdf',
        format: (filename, url) => `[${filename}|attachment](${url})`,
        acceptString: '.pdf',
      },
    },

    /** 内容格式配置 */
    CONTENT_FORMAT: {
      /** 内容前面的换行符 */
      BEFORE: '\n',
      /** 内容后面的换行符 */
      AFTER: '\n\n',
    },

    /** DOM选择器 */
    SELECTORS: {
      REPLY_CONTROL: '#reply-control', // 回复框
      EDITOR_CONTROLS: '.toolbar-visible.wmd-controls', // 编辑器控件
      EDITOR_INPUT: '.d-editor-input', // 编辑区域
      UPLOAD_BUTTON: '.btn.upload', // 上传按钮
    },

    /** 错误类型 */
    ERROR_TYPES: {
      NETWORK: 'network', // 网络连接问题
      SERVER: 'server', // 服务器错误
      PERMISSION: 'permission', // 权限问题
      FORMAT: 'format', // 响应格式错误
      FILETYPE: 'filetype', // 文件类型不支持
      FILESIZE: 'filesize', // 文件大小超限
      UNKNOWN: 'unknown', // 未知错误
    },
  };

  /**
   * 日志工具
   * @namespace
   */
  const Logger = {
    /**
     * 输出普通日志
     * @param {...any} args - 日志参数
     */
    log(...args) {
      if (CONFIG.DEBUG) {
        console.log('[小众论坛上传]', ...args);
      }
    },

    /**
     * 输出错误日志
     * @param {...any} args - 日志参数
     */
    error(...args) {
      if (CONFIG.DEBUG) {
        console.error('[小众论坛上传]', ...args);
      }
    },
  };

  /**
   * 应用状态管理
   * @namespace
   */
  const AppState = {
    /**
     * 保存所有上传状态
     * @type {Object.<string, {insertPosition: number, placeholderText: string, active: boolean}>}
     */
    uploads: {},

    /** 上传计数器 */
    uploadCounter: 0,

    /** DOM元素缓存 */
    elements: {
      replyControl: null,
      editorInput: null,
      editorControls: null,
      uploadButton: null,
    },

    /**
     * 生成唯一上传ID
     * @returns {string} 唯一ID
     */
    generateUploadId() {
      return `${Date.now()}-${++this.uploadCounter}`;
    },

    /**
     * 添加上传状态
     * @param {string} uploadId - 上传ID
     * @param {number} position - 插入位置
     * @param {string} placeholderText - 占位符文本
     */
    addUpload(uploadId, position, placeholderText) {
      this.uploads[uploadId] = {
        insertPosition: position,
        placeholderText,
        active: true,
        timestamp: Date.now(),
      };
    },

    /**
     * 获取上传状态
     * @param {string} uploadId - 上传ID
     * @returns {Object|null} 上传状态对象
     */
    getUpload(uploadId) {
      return this.uploads[uploadId] ?? null;
    },

    /**
     * 移除上传状态
     * @param {string} uploadId - 上传ID
     */
    removeUpload(uploadId) {
      delete this.uploads[uploadId];
    },
  };

  /**
   * DOM操作工具
   * @namespace
   */
  const DOMUtils = {
    /**
     * 判断回复控制面板是否处于打开状态
     * @param {HTMLElement} element - 回复框元素
     * @returns {boolean} 是否处于打开状态
     */
    isReplyControlOpen(element) {
      return element && element.id === 'reply-control' && !element.classList.contains('closed');
    },

    /**
     * 保存编辑器状态
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     * @returns {Object} 编辑器状态
     */
    saveEditorState(editor) {
      const { selectionStart, selectionEnd, scrollTop, value } = editor;
      return { selectionStart, selectionEnd, scrollTop, value };
    },

    /**
     * 触发输入事件
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     */
    triggerInputEvent(editor) {
      editor.dispatchEvent(new Event('input', { bubbles: true }));
    },
  };

  /**
   * 文件工具
   * @namespace
   */
  const FileUtils = {
    /**
     * 检查文件类型
     * @param {File} file - 文件对象
     * @returns {string|null} 文件类型或null
     */
    getFileType(file) {
      const { type: mimeType } = file;
      const entry = Object.entries(CONFIG.SUPPORTED_MIME_TYPES).find(([_, info]) =>
        info.test(mimeType),
      );
      return entry ? entry[0] : null;
    },

    /**
     * 检查文件是否合法
     * @param {File} file - 文件对象
     * @returns {{valid: boolean, error: string|null}} 检查结果及错误类型
     */
    validateFile(file) {
      // 检查类型
      const fileType = this.getFileType(file);
      if (fileType === null) {
        return {
          valid: false,
          error: CONFIG.ERROR_TYPES.FILETYPE,
        };
      }

      // 检查大小
      if (file.size > CONFIG.MAX_FILE_SIZE) {
        return {
          valid: false,
          error: CONFIG.ERROR_TYPES.FILESIZE,
        };
      }

      return { valid: true, error: null };
    },

    /**
     * 检查是否有文件在拖放数据中
     * @param {DataTransfer} dataTransfer - 数据传输对象
     * @returns {boolean} 是否有文件
     */
    hasFileInDataTransfer(dataTransfer) {
      if (!dataTransfer) return false;

      // 通过items检查
      if (dataTransfer.items?.length) {
        return [...dataTransfer.items].some((item) => item.kind === 'file');
      }

      // 通过types检查
      if (dataTransfer.types?.includes('Files')) {
        return true;
      }

      // 通过files检查
      return dataTransfer.files?.length > 0;
    },

    /**
     * 获取剪贴板中的文件
     * @param {ClipboardData} clipboardData - 剪贴板数据
     * @returns {File|null} 文件或null
     */
    getFileFromClipboard(clipboardData) {
      if (!clipboardData?.items) return null;

      for (const item of clipboardData.items) {
        if (item.kind === 'file') {
          return item.getAsFile();
        }
      }

      return null;
    },

    /**
     * 生成文件选择器的accept属性
     * @returns {string} accept属性值
     */
    generateAcceptString() {
      return Object.values(CONFIG.SUPPORTED_MIME_TYPES)
        .map((type) => type.acceptString)
        .join(',');
    },

    /**
     * 显示文件错误消息
     * @param {File} file - 文件对象
     * @param {string} errorType - 错误类型
     */
    showFileError(file, errorType) {
      const { name, type } = file;
      let message;

      switch (errorType) {
        case CONFIG.ERROR_TYPES.FILETYPE:
          message = `不支持的文件类型: ${type}`;
          break;
        case CONFIG.ERROR_TYPES.FILESIZE:
          message = `文件"${name}"超过${
            CONFIG.MAX_FILE_SIZE / (1024 * 1024)
          }MB大小限制,无法上传。`;
          break;
        default:
          message = `文件"${name}"无法上传: 未知错误`;
      }

      alert(message);
      Logger.log(message);
    },
  };

  /**
   * Markdown格式化工具
   * @namespace
   */
  const MarkdownFormatter = {
    /**
     * 获取文件对应的Markdown链接
     * @param {File} file - 文件对象
     * @param {string} url - 文件URL
     * @returns {string} Markdown格式文本
     */
    getMarkdownLink(file, url) {
      const { name: filename = `file_${Date.now()}` } = file;
      const fileType = FileUtils.getFileType(file);

      if (fileType && CONFIG.SUPPORTED_MIME_TYPES[fileType]) {
        return CONFIG.SUPPORTED_MIME_TYPES[fileType].format(filename, url);
      }

      // 默认格式
      return `[${filename}](${url})`;
    },

    /**
     * 获取占位符文本
     * @param {File} file - 文件对象
     * @param {string} uploadId - 上传ID
     * @returns {string} 占位符文本
     */
    getPlaceholderText(file, uploadId) {
      const fileType = FileUtils.getFileType(file);
      const prefix =
        fileType === 'image' || fileType === 'video' || fileType === 'audio' ? '!' : '';
      const suffix =
        fileType === 'video'
          ? '|video'
          : fileType === 'audio'
          ? '|audio'
          : fileType === 'pdf'
          ? '|attachment'
          : '';

      return `${prefix}[上传中...${uploadId}${suffix}]`;
    },

    /**
     * 获取上传失败的Markdown文本
     * @param {string} uploadId - 上传ID
     * @param {string} errorType - 错误类型
     * @returns {string} 失败提示文本
     */
    getFailureText(uploadId, errorType) {
      const errorMessages = {
        [CONFIG.ERROR_TYPES.NETWORK]: '网络错误',
        [CONFIG.ERROR_TYPES.SERVER]: '服务器错误',
        [CONFIG.ERROR_TYPES.PERMISSION]: '权限错误',
        [CONFIG.ERROR_TYPES.FORMAT]: '格式错误',
        [CONFIG.ERROR_TYPES.FILETYPE]: '类型不支持',
        [CONFIG.ERROR_TYPES.FILESIZE]: '文件过大',
        [CONFIG.ERROR_TYPES.UNKNOWN]: '未知错误',
      };

      const errorMessage = errorMessages[errorType] || '未知错误';
      return `[上传失败(${errorMessage})-${uploadId}]`;
    },

    /**
     * 格式化内容,添加配置的前后换行符
     * @param {string} content - 原始内容
     * @returns {string} 格式化后的内容
     */
    formatContent(content) {
      return CONFIG.CONTENT_FORMAT.BEFORE + content + CONFIG.CONTENT_FORMAT.AFTER;
    },
  };

  /**
   * 占位符管理器
   * @namespace
   */
  const PlaceholderManager = {
    /**
     * 插入占位符到编辑器
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     * @param {Object} editorState - 编辑器状态
     * @param {string} placeholderText - 占位符文本
     * @param {string} uploadId - 上传ID
     * @param {Function} onInserted - 占位符插入完成后的回调
     */
    insertPlaceholder(editor, editorState, placeholderText, uploadId, onInserted) {
      const { selectionStart: position, scrollTop } = editorState;
      const currentText = editor.value;

      // 使用配置的格式化占位符
      const completeText = MarkdownFormatter.formatContent(placeholderText);

      // 插入带换行的占位符
      editor.value =
        currentText.substring(0, position) + completeText + currentText.substring(position);

      // 触发输入事件
      DOMUtils.triggerInputEvent(editor);

      // 将光标移动到占位符后面
      const newCursorPosition = position + completeText.length;
      editor.selectionStart = newCursorPosition;
      editor.selectionEnd = newCursorPosition;
      editor.scrollTop = scrollTop;
      editor.focus();

      // 保存上传状态
      AppState.addUpload(uploadId, position, placeholderText);

      // 调用回调函数
      onInserted?.(uploadId, position);
    },

    /**
     * 查找占位符在文本中的位置
     * @param {string} text - 编辑器文本内容
     * @param {string} uploadId - 上传ID
     * @returns {Object|null} 占位符位置信息或null
     */
    findPlaceholder(text, uploadId) {
      const regex = new RegExp(`(!)?\\[上传中...${uploadId}(\\|[a-z]+)?\\]`);
      const match = regex.exec(text);

      if (match) {
        // 仅找到占位符文本本身
        return {
          start: match.index,
          end: match.index + match[0].length,
          text: match[0],
        };
      }

      return null;
    },

    /**
     * 替换编辑器中的占位符
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     * @param {string} uploadId - 上传ID
     * @param {string} markdownLink - Markdown链接文本
     * @param {Function} onReplaced - 替换完成后的回调
     */
    replacePlaceholder(editor, uploadId, markdownLink, onReplaced) {
      // 保存当前用户光标状态
      const currentState = DOMUtils.saveEditorState(editor);
      const { value: currentText } = currentState;

      // 查找占位符位置
      const placeholder = this.findPlaceholder(currentText, uploadId);

      // 如果找不到占位符,使用备选策略
      if (!placeholder) {
        Logger.error('找不到占位符,将添加到编辑器末尾');
        this._appendToEditor(editor, markdownLink, onReplaced);
        return;
      }

      // 计算长度变化
      const originalLength = placeholder.end - placeholder.start;
      const newLength = markdownLink.length;
      const lengthDiff = newLength - originalLength;

      // 替换内容
      const newText =
        currentText.substring(0, placeholder.start) +
        markdownLink +
        currentText.substring(placeholder.end);

      // 更新编辑器内容
      editor.value = newText;
      DOMUtils.triggerInputEvent(editor);

      // 调整光标位置
      this._adjustCursorPosition(editor, currentState, placeholder, lengthDiff);

      // 调用回调函数
      onReplaced?.(true);
    },

    /**
     * 调整光标位置
     * @private
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     * @param {Object} currentState - 当前编辑器状态
     * @param {Object} placeholder - 占位符信息
     * @param {number} lengthDiff - 长度变化
     */
    _adjustCursorPosition(editor, currentState, placeholder, lengthDiff) {
      const { selectionStart, selectionEnd, scrollTop } = currentState;
      let newSelectionStart = selectionStart;
      let newSelectionEnd = selectionEnd;

      // 情况1:光标在占位符之前 - 不需要调整
      if (selectionStart < placeholder.start) {
        // 不调整光标位置
      }
      // 情况2:光标在占位符范围内 - 移动到替换内容之后
      else if (selectionStart >= placeholder.start && selectionStart <= placeholder.end) {
        newSelectionStart = placeholder.start + (placeholder.end - placeholder.start) + lengthDiff;
        newSelectionEnd = newSelectionStart;
      }
      // 情况3:光标在占位符之后 - 根据内容长度变化调整
      else if (selectionStart > placeholder.end) {
        newSelectionStart = selectionStart + lengthDiff;
        newSelectionEnd = selectionEnd + lengthDiff;
      }

      // 设置新的光标位置
      editor.selectionStart = newSelectionStart;
      editor.selectionEnd = newSelectionEnd;
      editor.scrollTop = scrollTop;
      editor.focus();
    },

    /**
     * 将内容添加到编辑器末尾
     * @private
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     * @param {string} content - 要添加的内容
     * @param {Function} onAppended - 添加完成后的回调
     */
    _appendToEditor(editor, content, onAppended) {
      const currentText = editor.value;

      // 确保有换行分隔
      let newText = currentText;
      if (newText.length > 0 && !newText.endsWith('\n')) {
        newText += '\n';
      }

      // 添加新内容,使用配置的格式
      newText += MarkdownFormatter.formatContent(content);

      // 更新编辑器内容
      editor.value = newText;
      DOMUtils.triggerInputEvent(editor);

      // 移动光标到末尾
      editor.selectionStart = newText.length;
      editor.selectionEnd = newText.length;
      editor.focus();

      // 调用回调
      onAppended?.(false);
    },
  };

  /**
   * 上传服务
   * @namespace
   */
  const UploadService = {
    /**
     * 处理文件上传
     * @param {FileList|Array<File>} files - 文件列表
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     */
    processFiles(files, editor) {
      if (!files?.length) return;

      [...files].forEach((file) => {
        const validation = FileUtils.validateFile(file);

        if (validation.valid) {
          this.uploadFile(file, editor);
        } else {
          FileUtils.showFileError(file, validation.error);
        }
      });
    },

    /**
     * 上传单个文件
     * @param {File} file - 文件对象
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     */
    uploadFile(file, editor) {
      // 生成唯一ID
      const uploadId = AppState.generateUploadId();

      // 获取占位符文本
      const placeholderText = MarkdownFormatter.getPlaceholderText(file, uploadId);

      // 保存当前编辑器状态
      const editorState = DOMUtils.saveEditorState(editor);

      // 插入占位符
      PlaceholderManager.insertPlaceholder(editor, editorState, placeholderText, uploadId, () => {
        // 占位符插入后执行上传
        this._executeUpload(file, editor, uploadId);
      });
    },

    /**
     * 执行文件上传
     * @private
     * @param {File} file - 文件对象
     * @param {HTMLTextAreaElement} editor - 编辑器元素
     * @param {string} uploadId - 上传ID
     */
    async _executeUpload(file, editor, uploadId) {
      try {
        const result = await this.performUpload(file);

        // 生成Markdown链接
        const markdownLink = MarkdownFormatter.getMarkdownLink(file, result.url);

        // 替换占位符
        PlaceholderManager.replacePlaceholder(editor, uploadId, markdownLink, (success) => {
          if (success) {
            Logger.log('占位符替换成功:', uploadId);
          } else {
            Logger.log('占位符未找到,已添加到编辑器末尾:', uploadId);
          }
          // 清理上传状态
          AppState.removeUpload(uploadId);
        });
      } catch (error) {
        // 处理上传失败
        Logger.error('上传文件失败:', error);

        // 确定错误类型
        const errorType = this._categorizeError(error);

        // 生成错误文本
        const failureText = MarkdownFormatter.getFailureText(uploadId, errorType);

        // 替换占位符
        PlaceholderManager.replacePlaceholder(editor, uploadId, failureText, () => {
          // 清理上传状态
          AppState.removeUpload(uploadId);
        });
      }
    },

    /**
     * 分类错误类型
     * @private
     * @param {string|Error} error - 错误信息
     * @returns {string} 错误类型
     */
    _categorizeError(error) {
      const errorStr = error.toString().toLowerCase();

      if (
        errorStr.includes('network') ||
        errorStr.includes('failed to fetch') ||
        errorStr.includes('网络请求失败')
      ) {
        return CONFIG.ERROR_TYPES.NETWORK;
      }

      if (errorStr.includes('401') || errorStr.includes('403') || errorStr.includes('permission')) {
        return CONFIG.ERROR_TYPES.PERMISSION;
      }

      if (errorStr.includes('500') || errorStr.includes('503') || errorStr.includes('服务器')) {
        return CONFIG.ERROR_TYPES.SERVER;
      }

      if (errorStr.includes('解析') || errorStr.includes('parse') || errorStr.includes('format')) {
        return CONFIG.ERROR_TYPES.FORMAT;
      }

      return CONFIG.ERROR_TYPES.UNKNOWN;
    },

    /**
     * 执行文件上传到服务器
     * @param {File} file - 文件对象
     * @returns {Promise<{url: string, filename: string}>} 上传结果
     */
    performUpload(file) {
      return new Promise((resolve, reject) => {
        const { name: filename = `file_${Date.now()}` } = file;
        const formData = new FormData();
        formData.append('filename', filename);
        formData.append('file', file);

        const params = new URLSearchParams();
        Object.entries(CONFIG.UPLOAD_PARAMS).forEach(([key, val]) => {
          params.append(key, val);
        });

        const uploadUrl = `${CONFIG.UPLOAD_ENDPOINT}?${params.toString()}`;

        GM_xmlhttpRequest({
          method: 'POST',
          url: uploadUrl,
          data: formData,
          responseType: 'json',
          onload: (response) => {
            if (response.status !== 200) {
              return reject(`HTTP错误: ${response.status}`);
            }

            try {
              const data = response.response;
              if (!data?.[0]?.src) {
                return reject('无效的响应数据');
              }

              const fileUrl = CONFIG.ASSETS_URL_PREFIX + data[0].src;
              resolve({
                url: fileUrl,
                filename,
              });
            } catch (error) {
              reject('解析响应数据失败');
            }
          },
          onerror: () => reject('网络请求失败'),
        });
      });
    },
  };

  /**
   * 事件处理
   * @namespace
   */
  const EventHandlers = {
    /**
     * 粘贴事件处理
     * @param {ClipboardEvent} e - 粘贴事件
     */
    pasteHandler(e) {
      const editor = e.target;
      const file = FileUtils.getFileFromClipboard(e.clipboardData);

      // 如果没有文件,不干预原有处理
      if (!file) return;

      // 拦截事件,自己处理
      e.preventDefault();
      e.stopPropagation();

      // 验证文件
      const validation = FileUtils.validateFile(file);

      if (validation.valid) {
        // 文件有效,上传
        UploadService.uploadFile(file, editor);
      } else {
        // 文件无效,显示错误
        FileUtils.showFileError(file, validation.error);
      }
    },

    /**
     * 拖放处理
     * @param {DragEvent} e - 拖放事件
     */
    dropHandler(e) {
      // 检查是否有文件被拖放
      if (e.dataTransfer?.files?.length > 0) {
        // 阻止默认行为
        e.preventDefault();
        e.stopPropagation();

        // 查找编辑器元素
        const { editorInput: editor } = AppState.elements;
        if (editor) {
          // 处理所有拖放文件
          UploadService.processFiles(e.dataTransfer.files, editor);
        }
      }
    },

    /**
     * 上传按钮点击事件
     * @param {MouseEvent} e - 点击事件
     */
    uploadButtonClickHandler(e) {
      e.preventDefault();
      e.stopPropagation();

      // 创建文件选择器
      const fileInput = document.createElement('input');
      fileInput.type = 'file';
      fileInput.multiple = true;
      fileInput.accept = FileUtils.generateAcceptString();

      // 文件选择处理
      fileInput.addEventListener('change', function () {
        if (this.files?.length > 0) {
          const { editorInput: editor } = AppState.elements;
          if (!editor) return;

          // 处理所有选中的文件
          UploadService.processFiles(this.files, editor);
        }
      });

      // 触发文件选择对话框
      fileInput.click();
    },
  };

  /**
   * 初始化管理
   * @namespace
   */
  const Initializer = {
    /**
     * 查找并缓存DOM元素
     */
    findElements() {
      const replyControl = document.querySelector(CONFIG.SELECTORS.REPLY_CONTROL);
      if (!DOMUtils.isReplyControlOpen(replyControl)) return false;

      const editorControls = replyControl.querySelector(CONFIG.SELECTORS.EDITOR_CONTROLS);
      if (!editorControls) return false;

      // 一次性更新所有元素缓存
      Object.assign(AppState.elements, {
        replyControl,
        editorControls,
        editorInput: editorControls.querySelector(CONFIG.SELECTORS.EDITOR_INPUT),
        uploadButton: editorControls.querySelector(CONFIG.SELECTORS.UPLOAD_BUTTON),
      });

      return !!AppState.elements.editorInput;
    },

    /**
     * 设置事件处理器
     */
    setupEventHandlers() {
      const { editorInput: editor, editorControls, uploadButton } = AppState.elements;

      if (!editor || !editorControls) return false;

      // 设置粘贴处理
      editor.removeEventListener('paste', EventHandlers.pasteHandler);
      editor.addEventListener('paste', EventHandlers.pasteHandler, { capture: true });

      // 设置拖放处理
      editorControls.removeEventListener('drop', EventHandlers.dropHandler, true);
      editorControls.addEventListener('drop', EventHandlers.dropHandler, { capture: true });

      // 设置上传按钮(如果存在)
      if (uploadButton) {
        // 检查按钮是否隐藏
        const computedStyle = window.getComputedStyle(uploadButton);
        if (computedStyle.display === 'none' || uploadButton.style.display === 'none') {
          // 设置按钮为可见
          uploadButton.style.display = 'inline-flex';

          // 清除现有的事件处理器
          const newBtn = uploadButton.cloneNode(true);
          uploadButton.parentNode.replaceChild(newBtn, uploadButton);

          // 更新缓存引用
          AppState.elements.uploadButton = newBtn;

          // 添加新的点击事件
          newBtn.addEventListener('click', EventHandlers.uploadButtonClickHandler);

          Logger.log('上传按钮已设置为可见并添加事件监听器');
        }
      }

      Logger.log('事件处理器设置完成');
      return true;
    },

    /**
     * 初始化函数
     */
    init() {
      Logger.log('初始化小众软件论坛上传优化脚本...');

      // 查找元素
      if (this.findElements()) {
        this.setupEventHandlers();
      }

      // 观察编辑器的出现
      const observer = new MutationObserver((mutations) => {
        let needsUpdate = false;

        for (const mutation of mutations) {
          if (
            mutation.type === 'attributes' &&
            mutation.attributeName === 'class' &&
            DOMUtils.isReplyControlOpen(mutation.target)
          ) {
            needsUpdate = true;
            break;
          }
        }

        if (needsUpdate && this.findElements()) {
          this.setupEventHandlers();
        }
      });

      // MutationObserver 配置
      const replyControl = document.querySelector('#reply-control');
      if (replyControl) {
        observer.observe(replyControl, {
          attributes: true,
          attributeFilter: ['class'],
          childList: false,
          subtree: false,
        });
      } else {
        // 如果尚未找到目标元素,监听body以等待其创建
        observer.observe(document.body, {
          childList: true,
          subtree: true,
        });
      }

      Logger.log('初始化完成。');
    },
  };

  // 启动脚本
  Initializer.init();
})();

QingJ © 2025

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