// ==UserScript==
// @name Universal Discussions Copy Plugin
// @namespace http://tampermonkey.net/
// @version 2.0.0
// @description 通用论坛内容复制插件,支持 Markdown、HTML、PDF、PNG 格式导出,兼容多个主流论坛平台
// @author dext7r
// @match *://*/*
// @grant none
// @require https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/turndown/7.1.2/turndown.min.js
// ==/UserScript==
// {{CHENGQI:
// Action: Added; Timestamp: 2025-06-10 14:09:34 +08:00; Reason: P1-AR-001 创建通用平台检测架构; Principle_Applied: SOLID-S (单一职责原则);
// }}
(function () {
'use strict';
// 配置和常量
const CONFIG = {
DEBUG: true,
VERSION: '2.0.0',
PLUGIN_NAME: 'UniversalDiscussionsCopier',
SHORTCUTS: {
TOGGLE_PANEL: 'KeyC', // Ctrl/Cmd + Shift + C
}
};
// 日志系统
const Logger = {
log: (...args) => CONFIG.DEBUG && console.log(`[${CONFIG.PLUGIN_NAME}]`, ...args),
error: (...args) => console.error(`[${CONFIG.PLUGIN_NAME}]`, ...args),
warn: (...args) => CONFIG.DEBUG && console.warn(`[${CONFIG.PLUGIN_NAME}]`, ...args)
};
// 平台检测配置
const PLATFORM_CONFIGS = {
github: {
name: 'GitHub Discussions',
detect: () => window.location.hostname.includes('github.com') &&
(window.location.pathname.includes('/discussions/') ||
document.querySelector('[data-testid="discussion-timeline"]')),
selectors: {
container: '[data-testid="discussion-timeline"], .js-discussion-timeline, .discussion-timeline',
title: 'h1.gh-header-title, .js-issue-title, [data-testid="discussion-title"]',
content: '.timeline-comment-wrapper, .discussion-timeline-item, .js-timeline-item',
author: '.timeline-comment-header .author, .discussion-timeline-item .author'
}
},
reddit: {
name: 'Reddit',
detect: () => window.location.hostname.includes('reddit.com') &&
(document.querySelector('[data-testid="post-content"]') ||
document.querySelector('.Post')),
selectors: {
container: '[data-testid="post-content"], .Post, .thing.link',
title: 'h1, [data-testid="post-content"] h3, .Post h3',
content: '[data-testid="post-content"], .Post .usertext-body, .md',
author: '.author, [data-testid="comment_author_link"]'
}
},
stackoverflow: {
name: 'Stack Overflow',
detect: () => window.location.hostname.includes('stackoverflow.com') &&
(document.querySelector('.question') || document.querySelector('#question')),
selectors: {
container: '.question, #question, .answer',
title: '.question-hyperlink, h1[itemprop="name"]',
content: '.postcell, .post-text, .s-prose',
author: '.user-details, .user-info'
}
},
discourse: {
name: 'Discourse',
detect: () => document.querySelector('meta[name="generator"]')?.content?.includes('Discourse') ||
document.querySelector('.discourse-root') ||
window.location.pathname.includes('/t/'),
selectors: {
container: '.topic-post, .post-stream, #topic',
title: '.fancy-title, h1.title, .topic-title',
content: '.post, .cooked, .topic-body',
author: '.username, .post-username'
}
},
v2ex: {
name: 'V2EX',
detect: () => window.location.hostname.includes('v2ex.com') &&
(document.querySelector('.topic_content') || document.querySelector('#topic')),
selectors: {
container: '.topic_content, #topic, .reply_content',
title: '.header h1, .topic_title',
content: '.topic_content, .reply_content',
author: '.username, .dark'
}
},
generic: {
name: 'Generic Forum',
detect: () => true, // 总是返回true作为后备方案
selectors: {
container: 'article, main, .content, .post, .thread, .topic',
title: 'h1, h2, .title, .subject',
content: '.content, .message, .post-content, .body, p',
author: '.author, .username, .user'
}
}
};
// 内嵌 TailwindCSS 核心样式
const EMBEDDED_STYLES = `
/* TailwindCSS 核心类 */
.tw-fixed { position: fixed !important; }
.tw-absolute { position: absolute !important; }
.tw-relative { position: relative !important; }
.tw-top-4 { top: 1rem !important; }
.tw-right-4 { right: 1rem !important; }
.tw-bottom-4 { bottom: 1rem !important; }
.tw-z-50 { z-index: 50 !important; }
.tw-z-40 { z-index: 40 !important; }
.tw-bg-white { background-color: #ffffff !important; }
.tw-bg-blue-500 { background-color: #3b82f6 !important; }
.tw-bg-blue-600 { background-color: #2563eb !important; }
.tw-bg-gray-500 { background-color: #6b7280 !important; }
.tw-bg-orange-500 { background-color: #f97316 !important; }
.tw-bg-red-500 { background-color: #ef4444 !important; }
.tw-bg-purple-500 { background-color: #8b5cf6 !important; }
.tw-bg-green-50 { background-color: #f0fdf4 !important; }
.tw-text-white { color: #ffffff !important; }
.tw-text-gray-600 { color: #4b5563 !important; }
.tw-text-gray-800 { color: #1f2937 !important; }
.tw-text-green-600 { color: #059669 !important; }
.tw-border { border-width: 1px !important; }
.tw-border-gray-200 { border-color: #e5e7eb !important; }
.tw-border-green-200 { border-color: #bbf7d0 !important; }
.tw-rounded-lg { border-radius: 0.5rem !important; }
.tw-rounded-full { border-radius: 9999px !important; }
.tw-rounded { border-radius: 0.25rem !important; }
.tw-shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05) !important; }
.tw-shadow-xl { box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04) !important; }
.tw-p-4 { padding: 1rem !important; }
.tw-p-3 { padding: 0.75rem !important; }
.tw-px-4 { padding-left: 1rem !important; padding-right: 1rem !important; }
.tw-py-2 { padding-top: 0.5rem !important; padding-bottom: 0.5rem !important; }
.tw-py-3 { padding-top: 0.75rem !important; padding-bottom: 0.75rem !important; }
.tw-m-1 { margin: 0.25rem !important; }
.tw-mb-3 { margin-bottom: 0.75rem !important; }
.tw-mb-4 { margin-bottom: 1rem !important; }
.tw-w-80 { width: 20rem !important; }
.tw-w-14 { width: 3.5rem !important; }
.tw-h-14 { height: 3.5rem !important; }
.tw-w-full { width: 100% !important; }
.tw-w-1\\/2 { width: 50% !important; }
.tw-flex { display: flex !important; }
.tw-inline-block { display: inline-block !important; }
.tw-hidden { display: none !important; }
.tw-items-center { align-items: center !important; }
.tw-justify-center { justify-content: center !important; }
.tw-justify-between { justify-content: space-between !important; }
.tw-text-lg { font-size: 1.125rem !important; line-height: 1.75rem !important; }
.tw-text-sm { font-size: 0.875rem !important; line-height: 1.25rem !important; }
.tw-text-xs { font-size: 0.75rem !important; line-height: 1rem !important; }
.tw-font-semibold { font-weight: 600 !important; }
.tw-font-medium { font-weight: 500 !important; }
.tw-cursor-pointer { cursor: pointer !important; }
.tw-cursor-not-allowed { cursor: not-allowed !important; }
.tw-transition-all { transition-property: all !important; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1) !important; transition-duration: 150ms !important; }
.tw-transform { transform: translateX(0) !important; }
.tw-translate-x-full { transform: translateX(100%) !important; }
.tw-translate-x-0 { transform: translateX(0) !important; }
.tw-opacity-0 { opacity: 0 !important; }
.tw-opacity-100 { opacity: 1 !important; }
.hover\\:tw-bg-blue-600:hover { background-color: #2563eb !important; }
.hover\\:tw-bg-gray-600:hover { background-color: #4b5563 !important; }
.hover\\:tw-text-gray-700:hover { color: #374151 !important; }
.disabled\\:tw-bg-gray-400:disabled { background-color: #9ca3af !important; }
.disabled\\:tw-cursor-not-allowed:disabled { cursor: not-allowed !important; }
/* 自定义样式 */
.copier-highlight { outline: 2px solid #3b82f6 !important; outline-offset: 2px !important; background-color: rgba(59, 130, 246, 0.1) !important; }
.copier-selected { outline: 2px solid #10b981 !important; outline-offset: 2px !important; background-color: rgba(16, 185, 129, 0.1) !important; }
.copier-panel-enter { animation: slideInRight 0.3s ease-out !important; }
.copier-panel-exit { animation: slideOutRight 0.3s ease-in !important; }
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOutRight {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
// 全局状态管理
class AppState {
constructor() {
this.selectedContent = null;
this.currentPlatform = null;
this.isSelectionMode = false;
this.isInitialized = false;
this.ui = {
panel: null,
trigger: null
};
}
reset() {
this.selectedContent = null;
this.isSelectionMode = false;
this.clearHighlights();
}
clearHighlights() {
document.querySelectorAll('.copier-highlight, .copier-selected').forEach(el => {
el.classList.remove('copier-highlight', 'copier-selected');
});
}
}
// 平台检测器
class PlatformDetector {
static detect() {
Logger.log('开始检测平台...');
for (const [key, config] of Object.entries(PLATFORM_CONFIGS)) {
if (key === 'generic') continue; // 跳过通用配置
try {
if (config.detect()) {
Logger.log(`检测到平台: ${config.name}`);
return { key, ...config };
}
} catch (error) {
Logger.error(`平台检测错误 (${key}):`, error);
}
}
Logger.log('使用通用平台配置');
return { key: 'generic', ...PLATFORM_CONFIGS.generic };
}
static getSelectors(platform) {
return platform?.selectors || PLATFORM_CONFIGS.generic.selectors;
}
}
// 内容选择器
class ContentSelector {
constructor(appState, platform) {
this.appState = appState;
this.platform = platform;
this.selectors = PlatformDetector.getSelectors(platform);
}
enable() {
Logger.log('启用内容选择模式');
this.appState.isSelectionMode = true;
document.body.style.cursor = 'crosshair';
// 添加事件监听器
document.addEventListener('mouseover', this.handleMouseOver, true);
document.addEventListener('mouseout', this.handleMouseOut, true);
document.addEventListener('click', this.handleClick, true);
document.addEventListener('keydown', this.handleKeyDown, true);
}
disable() {
Logger.log('禁用内容选择模式');
this.appState.isSelectionMode = false;
document.body.style.cursor = '';
this.appState.clearHighlights();
// 移除事件监听器
document.removeEventListener('mouseover', this.handleMouseOver, true);
document.removeEventListener('mouseout', this.handleMouseOut, true);
document.removeEventListener('click', this.handleClick, true);
document.removeEventListener('keydown', this.handleKeyDown, true);
}
handleMouseOver = (e) => {
if (!this.appState.isSelectionMode) return;
const target = this.findSelectableContent(e.target);
if (target && !target.classList.contains('copier-selected')) {
target.classList.add('copier-highlight');
}
}
handleMouseOut = (e) => {
if (!this.appState.isSelectionMode) return;
e.target.classList.remove('copier-highlight');
}
handleClick = (e) => {
if (!this.appState.isSelectionMode) return;
e.preventDefault();
e.stopPropagation();
const target = this.findSelectableContent(e.target);
if (target) {
this.selectContent(target);
this.disable();
}
}
handleKeyDown = (e) => {
if (!this.appState.isSelectionMode) return;
if (e.key === 'Escape') {
e.preventDefault();
this.disable();
UI.updatePanelState();
}
}
findSelectableContent(element) {
// 尝试匹配平台特定的选择器
for (const selector of Object.values(this.selectors)) {
try {
if (element.matches && element.matches(selector)) {
return element;
}
const parent = element.closest(selector);
if (parent) {
return parent;
}
} catch (error) {
// 忽略无效选择器错误
}
}
// 通用内容检测
if (this.isContentElement(element)) {
return element;
}
return element.closest('article, .post, .comment, .message, .content') || element;
}
isContentElement(element) {
const contentTags = ['ARTICLE', 'SECTION', 'MAIN', 'DIV', 'P'];
const excludeClasses = ['nav', 'header', 'footer', 'sidebar', 'menu', 'toolbar'];
if (!contentTags.includes(element.tagName)) return false;
const className = element.className.toLowerCase();
if (excludeClasses.some(cls => className.includes(cls))) return false;
// 检查内容长度
const textContent = element.textContent?.trim() || '';
return textContent.length > 20;
}
selectContent(element) {
this.appState.reset();
this.appState.selectedContent = element;
element.classList.add('copier-selected');
Logger.log('内容已选择:', {
tag: element.tagName,
classes: element.className,
textLength: element.textContent?.length || 0
});
UI.updatePanelState();
}
}
// 导出管理器
class ExportManager {
constructor(appState, platform) {
this.appState = appState;
this.platform = platform;
}
generateFileName(format) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
const platformName = this.platform?.key || 'unknown';
return `${platformName}_content_${timestamp}.${format}`;
}
getCleanContent(options = {}) {
const { includeImages = true, includeStyles = false } = options;
if (!this.appState.selectedContent) {
Logger.error('没有选择的内容');
return null;
}
const clone = this.appState.selectedContent.cloneNode(true);
// 清理样式类
clone.classList.remove('copier-selected', 'copier-highlight');
clone.querySelectorAll('.copier-selected, .copier-highlight').forEach(el => {
el.classList.remove('copier-selected', 'copier-highlight');
});
// 处理图片
if (!includeImages) {
clone.querySelectorAll('img').forEach(el => el.remove());
}
// 处理样式
if (!includeStyles) {
clone.querySelectorAll('*').forEach(el => {
el.removeAttribute('style');
if (!includeImages) {
el.removeAttribute('class');
}
});
}
// 清理脚本和不必要的元素
clone.querySelectorAll('script, style, noscript').forEach(el => el.remove());
return clone;
}
async exportToMarkdown() {
try {
const content = this.getCleanContent();
if (!content) return;
if (typeof TurndownService === 'undefined') {
throw new Error('TurndownService 未加载');
}
const turndownService = new TurndownService({
headingStyle: 'atx',
codeBlockStyle: 'fenced',
emDelimiter: '*'
});
const markdown = turndownService.turndown(content.innerHTML);
this.downloadFile(markdown, this.generateFileName('md'), 'text/markdown');
Logger.log('Markdown 导出成功');
return true;
} catch (error) {
Logger.error('Markdown 导出失败:', error);
this.showError('Markdown 导出失败');
return false;
}
}
async exportToHTML() {
try {
const content = this.getCleanContent({ includeStyles: true });
if (!content) return;
const html = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>导出内容 - ${this.platform?.name || '未知平台'}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
max-width: 800px;
margin: 0 auto;
padding: 20px;
color: #333;
}
img { max-width: 100%; height: auto; }
pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding-left: 20px; color: #666; }
</style>
</head>
<body>
<header>
<h1>内容导出</h1>
<p><strong>来源:</strong> ${this.platform?.name || '未知平台'}</p>
<p><strong>导出时间:</strong> ${new Date().toLocaleString('zh-CN')}</p>
<p><strong>原始URL:</strong> <a href="${window.location.href}">${window.location.href}</a></p>
<hr>
</header>
<main>
${content.innerHTML}
</main>
</body>
</html>`;
this.downloadFile(html, this.generateFileName('html'), 'text/html');
Logger.log('HTML 导出成功');
return true;
} catch (error) {
Logger.error('HTML 导出失败:', error);
this.showError('HTML 导出失败');
return false;
}
}
async exportToPDF() {
try {
const content = this.getCleanContent({ includeStyles: true });
if (!content) return;
if (typeof window.jspdf === 'undefined') {
throw new Error('jsPDF 未加载');
}
const { jsPDF } = window.jspdf;
const pdf = new jsPDF();
// 创建临时容器用于渲染
const tempDiv = document.createElement('div');
tempDiv.style.cssText = `
position: absolute;
top: -9999px;
left: -9999px;
width: 800px;
background: white;
padding: 20px;
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
`;
tempDiv.innerHTML = `
<h1>内容导出</h1>
<p><strong>来源:</strong> ${this.platform?.name || '未知平台'}</p>
<p><strong>导出时间:</strong> ${new Date().toLocaleString('zh-CN')}</p>
<hr>
${content.innerHTML}
`;
document.body.appendChild(tempDiv);
// 使用 html2canvas 转换为图片
const canvas = await html2canvas(tempDiv, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
const imgData = canvas.toDataURL('image/png');
const imgWidth = 190;
const pageHeight = 297;
const imgHeight = (canvas.height * imgWidth) / canvas.width;
let heightLeft = imgHeight;
let position = 10;
// 添加第一页
pdf.addImage(imgData, 'PNG', 10, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
// 添加额外页面
while (heightLeft >= 0) {
position = heightLeft - imgHeight + 10;
pdf.addPage();
pdf.addImage(imgData, 'PNG', 10, position, imgWidth, imgHeight);
heightLeft -= pageHeight;
}
pdf.save(this.generateFileName('pdf'));
document.body.removeChild(tempDiv);
Logger.log('PDF 导出成功');
return true;
} catch (error) {
Logger.error('PDF 导出失败:', error);
this.showError('PDF 导出失败');
return false;
}
}
async exportToPNG() {
try {
const content = this.appState.selectedContent;
if (!content) return;
if (typeof html2canvas === 'undefined') {
throw new Error('html2canvas 未加载');
}
const canvas = await html2canvas(content, {
scale: 2,
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
// 创建下载链接
const link = document.createElement('a');
link.download = this.generateFileName('png');
link.href = canvas.toDataURL();
link.click();
Logger.log('PNG 导出成功');
return true;
} catch (error) {
Logger.error('PNG 导出失败:', error);
this.showError('PNG 导出失败');
return false;
}
}
downloadFile(content, filename, mimeType) {
try {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
Logger.error('文件下载失败:', error);
this.showError('文件下载失败');
}
}
showError(message) {
// 显示错误提示
const errorDiv = document.createElement('div');
errorDiv.className = 'tw-fixed tw-top-4 tw-left-1/2 tw-transform tw--translate-x-1/2 tw-bg-red-500 tw-text-white tw-px-4 tw-py-2 tw-rounded-lg tw-shadow-lg tw-z-50';
errorDiv.textContent = message;
document.body.appendChild(errorDiv);
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 3000);
}
}
// UI 管理器
class UI {
static init(appState, platform) {
this.appState = appState;
this.platform = platform;
this.contentSelector = new ContentSelector(appState, platform);
this.exportManager = new ExportManager(appState, platform);
this.injectStyles();
this.createTriggerButton();
this.createPanel();
this.bindEvents();
Logger.log('UI 初始化完成');
}
static injectStyles() {
const styleEl = document.createElement('style');
styleEl.id = 'universal-copier-styles';
styleEl.textContent = EMBEDDED_STYLES;
document.head.appendChild(styleEl);
}
static createTriggerButton() {
const button = document.createElement('button');
button.id = 'universal-copier-trigger';
button.className = 'tw-fixed tw-bottom-4 tw-right-4 tw-z-50 tw-bg-blue-500 hover:tw-bg-blue-600 tw-text-white tw-w-14 tw-h-14 tw-rounded-full tw-shadow-xl tw-flex tw-items-center tw-justify-center tw-cursor-pointer tw-transition-all';
button.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 5C8 3.34315 9.34315 2 11 2H20C21.6569 2 23 3.34315 23 5V14C23 15.6569 21.6569 17 20 17H18V19C18 20.6569 16.6569 22 15 22H6C4.34315 22 3 20.6569 3 19V10C3 8.34315 4.34315 7 6 7H8V5Z" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M8 7V15C8 16.1046 8.89543 17 10 17H18" stroke="currentColor" stroke-width="2" fill="none"/>
</svg>
`;
button.title = `${this.platform?.name || '通用论坛'} 内容复制工具\n快捷键: Ctrl/Cmd + Shift + C`;
button.addEventListener('click', () => this.togglePanel());
document.body.appendChild(button);
this.appState.ui.trigger = button;
}
static createPanel() {
const panel = document.createElement('div');
panel.id = 'universal-copier-panel';
panel.className = 'tw-fixed tw-top-4 tw-right-4 tw-z-40 tw-bg-white tw-shadow-xl tw-rounded-lg tw-border tw-border-gray-200 tw-w-80 tw-p-4 tw-transform tw-translate-x-full tw-transition-all tw-opacity-0';
panel.innerHTML = `
<div class="tw-flex tw-justify-between tw-items-center tw-mb-4">
<h3 class="tw-text-lg tw-font-semibold tw-text-gray-800">内容复制工具</h3>
<button id="close-panel" class="tw-text-gray-600 hover:tw-text-gray-700 tw-cursor-pointer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
</div>
<div class="tw-mb-3">
<p class="tw-text-sm tw-text-gray-600 tw-mb-2">
检测到平台: <span class="tw-font-medium tw-text-blue-600">${this.platform?.name || '通用论坛'}</span>
</p>
</div>
<div id="selection-info" class="tw-bg-green-50 tw-border tw-border-green-200 tw-rounded tw-p-3 tw-mb-3 tw-hidden">
<p class="tw-text-sm tw-text-green-600 tw-font-medium">✓ 内容已选择</p>
<p class="tw-text-xs tw-text-green-600">可以开始导出了</p>
</div>
<button id="select-content" class="tw-w-full tw-bg-blue-500 hover:tw-bg-blue-600 disabled:tw-bg-gray-400 disabled:tw-cursor-not-allowed tw-text-white tw-py-3 tw-px-4 tw-rounded tw-cursor-pointer tw-transition-all tw-mb-3 tw-font-medium">
选择内容
</button>
<div class="tw-mb-3">
<p class="tw-text-sm tw-text-gray-600 tw-mb-2 tw-font-medium">导出格式:</p>
<div class="tw-flex tw-flex-wrap">
<button id="export-markdown" class="tw-bg-gray-500 hover:tw-bg-gray-600 disabled:tw-bg-gray-400 disabled:tw-cursor-not-allowed tw-text-white tw-text-xs tw-py-2 tw-px-3 tw-rounded tw-cursor-pointer tw-transition-all tw-m-1 tw-w-1/2" style="width: calc(50% - 0.5rem);" disabled>
Markdown
</button>
<button id="export-html" class="tw-bg-orange-500 hover:tw-bg-gray-600 disabled:tw-bg-gray-400 disabled:tw-cursor-not-allowed tw-text-white tw-text-xs tw-py-2 tw-px-3 tw-rounded tw-cursor-pointer tw-transition-all tw-m-1 tw-w-1/2" style="width: calc(50% - 0.5rem);" disabled>
HTML
</button>
<button id="export-pdf" class="tw-bg-red-500 hover:tw-bg-gray-600 disabled:tw-bg-gray-400 disabled:tw-cursor-not-allowed tw-text-white tw-text-xs tw-py-2 tw-px-3 tw-rounded tw-cursor-pointer tw-transition-all tw-m-1 tw-w-1/2" style="width: calc(50% - 0.5rem);" disabled>
PDF
</button>
<button id="export-png" class="tw-bg-purple-500 hover:tw-bg-gray-600 disabled:tw-bg-gray-400 disabled:tw-cursor-not-allowed tw-text-white tw-text-xs tw-py-2 tw-px-3 tw-rounded tw-cursor-pointer tw-transition-all tw-m-1 tw-w-1/2" style="width: calc(50% - 0.5rem);" disabled>
PNG
</button>
</div>
</div>
<div class="tw-text-xs tw-text-gray-600">
<p>💡 提示: 使用 Ctrl/Cmd + Shift + C 快速切换面板</p>
<p>🔗 版本: ${CONFIG.VERSION} | 支持多平台论坛</p>
</div>
`;
document.body.appendChild(panel);
this.appState.ui.panel = panel;
}
static bindEvents() {
// 关闭面板
document.getElementById('close-panel')?.addEventListener('click', () => {
this.hidePanel();
});
// 选择内容
document.getElementById('select-content')?.addEventListener('click', () => {
this.contentSelector.enable();
this.hidePanel();
});
// 导出按钮
const exportButtons = [
{ id: 'export-markdown', handler: () => this.exportManager.exportToMarkdown() },
{ id: 'export-html', handler: () => this.exportManager.exportToHTML() },
{ id: 'export-pdf', handler: () => this.exportManager.exportToPDF() },
{ id: 'export-png', handler: () => this.exportManager.exportToPNG() }
];
exportButtons.forEach(({ id, handler }) => {
document.getElementById(id)?.addEventListener('click', handler);
});
// 快捷键
document.addEventListener('keydown', (e) => {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
const modifierKey = isMac ? e.metaKey : e.ctrlKey;
if (modifierKey && e.shiftKey && e.code === CONFIG.SHORTCUTS.TOGGLE_PANEL) {
e.preventDefault();
this.togglePanel();
}
});
}
static togglePanel() {
const panel = this.appState.ui.panel;
if (!panel) return;
const isHidden = panel.classList.contains('tw-translate-x-full');
if (isHidden) {
this.showPanel();
} else {
this.hidePanel();
}
}
static showPanel() {
const panel = this.appState.ui.panel;
if (!panel) return;
panel.classList.remove('tw-translate-x-full', 'tw-opacity-0');
panel.classList.add('tw-translate-x-0', 'tw-opacity-100', 'copier-panel-enter');
this.updatePanelState();
Logger.log('面板已显示');
}
static hidePanel() {
const panel = this.appState.ui.panel;
if (!panel) return;
panel.classList.remove('tw-translate-x-0', 'tw-opacity-100', 'copier-panel-enter');
panel.classList.add('tw-translate-x-full', 'tw-opacity-0', 'copier-panel-exit');
Logger.log('面板已隐藏');
}
static updatePanelState() {
const hasSelection = !!this.appState.selectedContent;
// 更新选择信息显示
const selectionInfo = document.getElementById('selection-info');
if (selectionInfo) {
if (hasSelection) {
selectionInfo.classList.remove('tw-hidden');
} else {
selectionInfo.classList.add('tw-hidden');
}
}
// 更新导出按钮状态
const exportButtons = ['export-markdown', 'export-html', 'export-pdf', 'export-png'];
exportButtons.forEach(id => {
const button = document.getElementById(id);
if (button) {
button.disabled = !hasSelection;
}
});
// 更新选择按钮文本
const selectButton = document.getElementById('select-content');
if (selectButton) {
selectButton.textContent = hasSelection ? '重新选择内容' : '选择内容';
}
}
}
// 库依赖检查器
class LibraryChecker {
static check() {
const libraries = {
'html2canvas': () => typeof html2canvas !== 'undefined',
'jsPDF': () => typeof window.jspdf !== 'undefined',
'TurndownService': () => typeof TurndownService !== 'undefined'
};
const missing = [];
const available = [];
for (const [name, check] of Object.entries(libraries)) {
if (check()) {
available.push(name);
} else {
missing.push(name);
}
}
Logger.log('库检查结果:', { available, missing });
if (missing.length > 0) {
Logger.warn('缺少依赖库:', missing);
return false;
}
return true;
}
}
// 主应用程序
class UniversalDiscussionsCopier {
constructor() {
this.appState = new AppState();
this.platform = null;
}
async init() {
if (this.appState.isInitialized) {
Logger.log('插件已初始化,跳过重复初始化');
return;
}
try {
Logger.log(`插件初始化开始 - 版本 ${CONFIG.VERSION}`);
// 检测平台
this.platform = PlatformDetector.detect();
this.appState.currentPlatform = this.platform;
// 等待依赖库加载
if (!await this.waitForLibraries()) {
Logger.error('依赖库加载超时,插件可能无法完全工作');
}
// 初始化UI
UI.init(this.appState, this.platform);
this.appState.isInitialized = true;
Logger.log('插件初始化完成');
} catch (error) {
Logger.error('初始化过程中发生错误:', error);
}
}
async waitForLibraries(maxAttempts = 10, interval = 1000) {
let attempts = 0;
return new Promise((resolve) => {
const checkLibraries = () => {
attempts++;
Logger.log(`检查依赖库 (${attempts}/${maxAttempts})`);
if (LibraryChecker.check()) {
resolve(true);
} else if (attempts < maxAttempts) {
setTimeout(checkLibraries, interval);
} else {
resolve(false);
}
};
checkLibraries();
});
}
}
// 初始化应用
const app = new UniversalDiscussionsCopier();
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
Logger.log('DOM 载入完成,延迟初始化...');
setTimeout(() => app.init(), 1000);
});
} else {
Logger.log('页面已载入,延迟初始化...');
setTimeout(() => app.init(), 1000);
}
// 导出到全局作用域(用于调试)
if (CONFIG.DEBUG) {
window.UniversalDiscussionsCopier = {
app,
Logger,
CONFIG,
PLATFORM_CONFIGS
};
}
})();