// ==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) => ``,
acceptString: 'image/*',
},
'video': {
test: (type) => type.startsWith('video/'),
format: (filename, url) => ``,
acceptString: 'video/*',
},
'audio': {
test: (type) => type.startsWith('audio/'),
format: (filename, 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();
})();