Universal Discussions Copy Plugin

通用论坛内容复制插件,支持 Markdown、HTML、PDF、PNG 格式导出,兼容多个主流论坛平台

// ==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
    };
  }

})(); 
长期地址
遇到问题?请前往 GitHub 提 Issues。