复制格式转换(Markdown)

选中内容后浮现按钮(固定右上角),点击自动复制为完整 Markdown 格式,确保排版与原文一致。支持数学公式、代码块语言标识、表格对齐。简化浮现逻辑。

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         复制格式转换(Markdown)
// @namespace    http://tampermonkey.net/
// @version      2.2
// @description  选中内容后浮现按钮(固定右上角),点击自动复制为完整 Markdown 格式,确保排版与原文一致。支持数学公式、代码块语言标识、表格对齐。简化浮现逻辑。
// @author       KiwiFruit
// @match        *://*/*
// @grant        GM_setClipboard
// @license      MIT
// ==/UserScript==
(function () {
    'use strict';

    const config = {
        preserveEmptyLines: false, // 是否保留空行
        addSeparators: true, // 数学公式使用 $$ 分隔符
        mathSelector: '.math-formula', // 数学公式自定义选择器
    };

    // 创建浮动按钮 (固定在右上角)
    const floatingButton = createFloatingButton();
    // 创建预览窗口 (固定在按钮下方)
    const previewWindow = createPreviewWindow();

    /**
     * 创建浮动按钮元素 (固定位置)
     * @returns {HTMLElement} 按钮元素
     */
    function createFloatingButton() {
        const button = document.createElement('button');
        button.id = 'markdownCopyButton';
        button.innerText = '复制为 MD (Ctrl+Shift+C)';
        Object.assign(button.style, {
            position: 'fixed',
            top: '20px',
            right: '20px', // 固定在右上角
            padding: '8px 15px',
            backgroundColor: '#007bff',
            color: '#fff',
            border: 'none',
            borderRadius: '4px',
            cursor: 'pointer',
            zIndex: '10000',
            display: 'none', // 初始隐藏
            boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
            transition: 'opacity 0.2s ease-in-out',
            fontSize: '13px',
            whiteSpace: 'nowrap'
        });
        document.body.appendChild(button);
        return button;
    }

    /**
     * 创建预览窗口元素 (固定在按钮下方)
     * @returns {HTMLElement} 预览窗口元素
     */
    function createPreviewWindow() {
        const preview = document.createElement('div');
        preview.id = 'markdownPreview';
        Object.assign(preview.style, {
            position: 'fixed',
            top: '60px', // 固定在按钮下方
            right: '20px',
            width: '300px',
            maxHeight: '400px',
            overflowY: 'auto',
            overflowX: 'auto',
            padding: '10px',
            backgroundColor: '#fff',
            border: '1px solid #ddd',
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
            zIndex: '9999',
            display: 'none', // 初始隐藏
            fontFamily: 'monospace',
            fontSize: '13px',
            whiteSpace: 'pre-wrap',
            wordWrap: 'break-word',
            boxSizing: 'border-box'
        });
        document.body.appendChild(preview);
        return preview;
    }

    /**
     * 显示浮动按钮 (简单显示)
     */
    function showFloatingButton() {
        floatingButton.style.display = 'block';
    }

    /**
     * 隐藏浮动按钮和预览窗口
     */
    function hideFloatingButton() {
        floatingButton.style.display = 'none';
        previewWindow.style.display = 'none';
    }


    // --- Markdown 转换逻辑 (保持不变) ---

    function convertToMarkdown(node) {
        if (node.nodeType === Node.TEXT_NODE) {
            let text = node.textContent;
            return config.preserveEmptyLines ? text : text.trim() || '';
        }
        if (node.nodeType === Node.ELEMENT_NODE) {
            const tagName = node.tagName.toLowerCase();
            switch (tagName) {
                case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
                    return `\n${formatHeader(node)}\n`;
                case 'br':
                    return '\n';
                case 'p':
                    return `\n${formatParagraph(node)}\n`;
                case 'ul': case 'ol':
                    return `\n${formatList(node, tagName === 'ol')}\n`;
                case 'blockquote':
                    return `\n${formatBlockquote(node)}\n`;
                case 'pre':
                    return `\n${formatCodeBlock(node)}\n`;
                case 'code':
                    return formatInlineCode(node);
                case 'a':
                    return formatLink(node);
                case 'img':
                    return formatImage(node);
                case 'strong': case 'b':
                    return formatBold(node);
                case 'em': case 'i':
                    return formatItalic(node);
                case 'del':
                    return formatStrikethrough(node);
                case 'hr':
                    return '\n---\n';
                case 'table':
                    return `\n${formatTable(node)}\n`;
                case 'span': case 'div':
                    if (node.matches(config.mathSelector)) {
                        return `\n${formatMath(node)}\n`;
                    }
                    return processChildren(node);
                default:
                    return processChildren(node);
            }
        }
        return '';
    }

    function formatHeader(node) {
        const level = parseInt(node.tagName.slice(1));
        const content = processChildren(node).trim();
        return `${'#'.repeat(level)} ${content}`;
    }

    function formatParagraph(node) {
        const content = processChildren(node).trim();
        return `${content}`;
    }

    function formatList(node, isOrdered) {
        let markdown = '';
        let index = 1;
        for (const li of node.children) {
            const content = processChildren(li).trim();
            markdown += `${isOrdered ? `${index++}.` : '-' } ${content}\n`;
        }
        return `\n${markdown}\n`;
    }

    function formatBlockquote(node) {
        const content = processChildren(node).trim();
        return `> ${content.replace(/\n/g, '\n> ')}\n`;
    }

    // 改进代码块语言检测
    function formatCodeBlock(node) {
        let langMatch = node.className.match(/language-(\w+)/);
        let lang = langMatch ? langMatch[1] : '';
        if (!lang) {
            const codeEl = node.querySelector('code');
            if (codeEl) {
                langMatch = codeEl.className.match(/language-(\w+)/);
                lang = langMatch ? langMatch[1] : '';
            }
        }
        const code = node.textContent.trim();
        return `\`\`\`${lang}\n${code}\n\`\`\``;
    }

    function formatInlineCode(node) {
        return `\`${node.textContent.trim()}\``;
    }

    function formatLink(node) {
        const text = processChildren(node).trim();
        const href = node.href || '';
        return `[${text}](${href})`;
    }

    function formatImage(node) {
        const alt = node.alt || 'Image';
        const src = node.src || '';
        return `![${alt}](${src})`;
    }

    function formatBold(node) {
        const content = processChildren(node).trim();
        return ` **${content}** `;
    }

    function formatItalic(node) {
        const content = processChildren(node).trim();
        return ` *${content}* `;
    }

    function formatStrikethrough(node) {
        const content = processChildren(node).trim();
        return `~~${content}~~`;
    }

    // 改进表格对齐支持
    function formatTable(node) {
        const rows = Array.from(node.rows);
        if (rows.length === 0) return '';
        const headers = Array.from(rows[0].cells);
        const separatorCells = headers.map(cell => {
            const style = window.getComputedStyle(cell);
            const align = style.textAlign;
            if (align === 'right') return '---:';
            if (align === 'center') return ':---:';
            return '---';
        });
        const separator = `| ${separatorCells.join(' | ')} |`;

        const headerRow = `| ${headers.map(cell => cell.textContent.trim()).join(' | ')} |`;
        const dataRows = rows.slice(1).map(row => {
            const cells = Array.from(row.cells).map(cell => {
                return processChildren(cell).trim().replace(/\n/g, ' ');
            });
            return `| ${cells.join(' | ')} |`;
        }).join('\n');
        return `${headerRow}\n${separator}\n${dataRows}`;
    }

    function formatMath(node) {
        const formula = node.textContent.trim();
        if (config.addSeparators) {
            return `$$\n${formula}\n$$`;
        } else {
            return `$${formula}$ `;
        }
    }

    function processChildren(node) {
        let result = '';
        for (const child of node.childNodes) {
            result += convertToMarkdown(child);
        }
        return result;
    }

    function extractAndConvertToMarkdown(range) {
        const fragment = range.cloneContents();
        const nodes = Array.from(fragment.childNodes);
        return nodes.map(node => convertToMarkdown(node)).join('').trim();
    }

    async function copyToClipboard(text) {
        try {
            if (typeof GM_setClipboard === 'function') {
                GM_setClipboard(text);
                return true;
            } else {
                await navigator.clipboard.writeText(text);
                console.log('Markdown 已复制到剪贴板');
                return true;
            }
        } catch (err) {
            console.error('复制失败:', err);
            return false;
        }
    }

    /**
     * 显示提示信息 (修复动画)
     * @param {string} message 提示内容
     */
    function showToast(message) {
        // 动态注入关键帧动画样式
        if (!document.getElementById('markdown-copy-toast-styles')) {
            const style = document.createElement('style');
            style.id = 'markdown-copy-toast-styles';
            style.textContent = `
                @keyframes fadeInOut {
                    0% { opacity: 0; transform: translateY(20px); }
                    10% { opacity: 1; transform: translateY(0); }
                    90% { opacity: 1; transform: translateY(0); }
                    100% { opacity: 0; transform: translateY(20px); }
                }
            `;
            document.head.appendChild(style);
        }

        const toast = document.createElement('div');
        Object.assign(toast.style, {
            position: 'fixed',
            bottom: '20px',
            right: '20px',
            padding: '10px 20px',
            backgroundColor: '#333',
            color: '#fff',
            borderRadius: '5px',
            zIndex: '10001',
            boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
            animation: 'fadeInOut 2.5s ease-in-out forwards',
            pointerEvents: 'none'
        });
        toast.innerText = message;
        document.body.appendChild(toast);
        setTimeout(() => {
             if (toast.parentNode) { toast.remove(); }
        }, 2500);
    }

    // --- 事件监听器 ---
    document.addEventListener('mouseup', handleMouseUp);
    document.addEventListener('keydown', handleKeyDown);
    document.addEventListener('mousedown', handleMouseDown);

    /**
     * 鼠标释放事件处理 (简化逻辑)
     * @param {Event} event 鼠标事件
     */
    function handleMouseUp(event) {
        // 添加小延迟,确保 selection 状态是最新的
        setTimeout(() => {
            const selection = window.getSelection();
            // 简化的浮现逻辑:只要选区存在且未折叠,就显示按钮
            if (selection && !selection.isCollapsed) {
                showFloatingButton();
                floatingButton.onclick = async () => {
                    try {
                        let currentRange;
                        try {
                            currentRange = selection.getRangeAt(0);
                        } catch (e) {
                            throw new Error("选区在复制时已丢失,请重新选择内容。");
                        }

                        const markdownContent = extractAndConvertToMarkdown(currentRange);

                        // 显示预览窗口
                        previewWindow.innerText = markdownContent;
                        previewWindow.style.display = 'block';

                        const success = await copyToClipboard(markdownContent);
                        if (success) {
                            showToast('内容已复制为 Markdown 格式!');
                        } else {
                            showToast('复制失败,请重试!');
                        }
                    } catch (err) {
                        console.error('处理内容时出错:', err);
                        showToast(`发生错误:${err.message}`);
                    } finally {
                        // 不再立即隐藏
                    }
                };
            } else {
                // 如果没有选区,则隐藏按钮
                hideFloatingButton();
            }
        }, 10); // 10ms 延迟
    }

    /**
     * 键盘事件处理
     * @param {Event} event 键盘事件
     */
    function handleKeyDown(event) {
        if (event.ctrlKey && event.shiftKey && event.code === 'KeyC') {
            event.preventDefault(); // 阻止默认行为
            handleMouseUp(event); // 触发复制逻辑
        }
    }

    /**
     * 鼠标按下事件处理(用于点击外部区域隐藏按钮)
     * @param {Event} event 鼠标事件
     */
    function handleMouseDown(event) {
        // 如果点击的不是按钮或预览窗口本身,则隐藏它们
        if (!floatingButton.contains(event.target) && !previewWindow.contains(event.target)) {
            hideFloatingButton();
        }
    }

})();