Greasy Fork

Greasy Fork is available in English.

文本网页自由复制-Markdown (可拖动按钮)

修复了Turndown库加载的竞态条件问题,并优化了复制逻辑,确保稳定可靠地将选定内容复制为Markdown。

// ==UserScript==
// @name         文本网页自由复制-Markdown (可拖动按钮)
// @namespace    http://tampermonkey.net/
// @version      3.2.0
// @description  修复了Turndown库加载的竞态条件问题,并优化了复制逻辑,确保稳定可靠地将选定内容复制为Markdown。
// @author       shenfangda
// @match        *://*/*
// @exclude      https://accounts.google.com/*
// @exclude      https://*.google.com/sorry/*
// @exclude      https://mail.google.com/*
// @exclude      /^https?://localhost[:/]/
// @exclude      /^file:///*/
// @grant        GM_setClipboard
// @require      https://unpkg.com/turndown/dist/turndown.js
// @license      MIT
// @icon         data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0iIzRDQUY1MCIgd2lkdGg9IjQ4cHgiIGhlaWdodD0iNDhweCI+PHBhdGggZD0iTTAgMGgyNHYyNEgwVjB6IiBmaWxsPSJub25lIi8+PHBhdGggZD0iTTIxIDNINWMtMS4xIDAtMiAuOS0yIDJ2MTRjMCAxLjEuOSAyIDIgMmgxNGMxLjEgMCAyLS45IDItMlY1YwAtMS4xLS45LTItMi0yem0tOSAyaDZ2MkgxMlY1em0wIDRoNnYySDEydjloLTJ2LTJIMTBWN2gydjJ6bS03IDRoMlY3SDVWMTFoMlY5em0wIDRoMnYySDV2LTJ6bTEyLTYuNWMyLjQ5IDAgNC41IDIuMDEgNC41IDQuNXM LTIuMDEgNC41LTQuNSA0LjUtNC41LTIuMDEtNC41LTQuNSAyLjAxLTQuNSA0LjUtNC41eiIvPjwvc3ZnPg==
// ==/UserScript==

(function () {
    'use strict';

    // --- Configuration ---
    const BUTTON_TEXT_DEFAULT = '复制为 Markdown';
    const BUTTON_TEXT_SELECTING_FREE = '选择区域中... (ESC 取消)';
    const BUTTON_TEXT_SELECTING_DIV = '点击元素复制 (ESC 取消)';
    const BUTTON_TEXT_COPIED = '已复制!';
    const BUTTON_TEXT_FAILED = '复制失败!';
    const TEMP_MESSAGE_DURATION = 2000; // ms
    const DEBUG = true; // 设置为 true 以获取更详细的日志记录

    // --- Logging ---
    const log = (msg) => console.log(`[Markdown - 复制] ${msg}`);
    const debugLog = (msg) => DEBUG && console.log(`[Markdown - 复制调试] ${msg}`);

    // --- State ---
    let isSelecting = false;
    let isDivMode = false;
    let startX, startY;
    let selectionBox = null;
    let highlightedDiv = null;
    let copyBtn = null;
    let originalButtonText = BUTTON_TEXT_DEFAULT;
    let messageTimeout = null;
    let turndownService = null;

    // --- DOM Ready Check ---
    function onDOMReady(callback) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', callback);
        } else {
            callback();
        }
    }

    // --- Main Initialization ---
    function initScript() {
        log(`Attempting init on ${window.location.href}`);

        if (window.self !== window.top) {
            log('Script is running in an iframe, aborting.');
            return;
        }
        if (!document.body || !document.head) {
            log('Error: document.body or document.head not found. Retrying...');
            setTimeout(initScript, 500);
            return;
        }

        log('DOM ready, initializing script.');
        turndownService = new TurndownService({
            headingStyle: 'atx',
            hr: '---',
            bulletListMarker: '-',
            codeBlockStyle: 'fenced',
            emDelimiter: '*',
        });
        injectStyles();
        if (!createButton()) return;
        setupEventListeners();
        log('Initialization complete.');
    }

    // --- CSS Injection ---
    function injectStyles() {
        const STYLES = `
            .markdown-copy-btn {
                position: fixed;
                top: 15px;
                right: 15px;
                z-index: 2147483646;
                padding: 8px 14px;
                background-color: #4CAF50;
                color: white;
                border: none;
                border-radius: 5px;
                cursor: pointer;
                font-size: 13px;
                font-family: sans-serif;
                box-shadow: 0 2px 5px rgba(0,0,0,0.2);
                transition: background-color 0.2s ease-in-out, transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
                line-height: 1.4;
                text-align: center;
                user-select: none;
            }
            .markdown-copy-btn:hover {
                background-color: #45a049;
                transform: translateY(-1px);
                box-shadow: 0 4px 8px rgba(0,0,0,0.25);
            }
            .markdown-copy-btn.mc-copied { background-color: #3a8f40; }
            .markdown-copy-btn.mc-failed { background-color: #c0392b; }
            .markdown-copy-btn.dragging {
                cursor: move;
                transform: scale(1.05);
                box-shadow: 0 6px 12px rgba(0,0,0,0.3);
            }
            .markdown-copy-selection-box {
                position: absolute;
                border: 2px dashed #4CAF50;
                background-color: rgba(76, 175, 80, 0.1);
                z-index: 2147483645;
                pointer-events: none;
                box-sizing: border-box;
            }
            .markdown-copy-div-highlight {
                outline: 2px solid #4CAF50!important;
                background-color: rgba(76, 175, 80, 0.1)!important;
                box-shadow: inset 0 0 0 2px rgba(76, 175, 80, 0.5)!important;
                transition: all 0.1s ease-in-out;
                cursor: pointer;
            }
        `;
        try {
            const styleSheet = document.createElement('style');
            styleSheet.id = 'markdown-copy-styles';
            styleSheet.textContent = STYLES.trim().replace(/\s{2,}/g, ' ');
            document.head.appendChild(styleSheet);
            debugLog('Styles injected.');
        } catch (error) {
            log(`Error injecting styles: ${error.message}`);
        }
    }

    // --- Button Creation ---
    function createButton() {
        if (document.getElementById('markdown-copy-btn-main')) {
            log('Button already exists.');
            copyBtn = document.getElementById('markdown-copy-btn-main');
            return true;
        }
        try {
            copyBtn = document.createElement('button');
            copyBtn.id = 'markdown-copy-btn-main';
            copyBtn.className = 'markdown-copy-btn';
            copyBtn.textContent = BUTTON_TEXT_DEFAULT;
            originalButtonText = BUTTON_TEXT_DEFAULT;
            document.body.appendChild(copyBtn);

            const savedPos = localStorage.getItem('markdown-copy-btn-pos');
            if (savedPos) {
                try {
                    const pos = JSON.parse(savedPos);
                    if (pos && typeof pos.left !== 'undefined' && typeof pos.top !== 'undefined') {
                        copyBtn.style.left = pos.left;
                        copyBtn.style.top = pos.top;
                        copyBtn.style.right = 'auto';
                        debugLog(`Restored button position to ${pos.left}, ${pos.top}`);
                    }
                } catch (e) {
                    log('Error parsing saved button position.');
                    localStorage.removeItem('markdown-copy-btn-pos');
                }
            }

            debugLog('Button created and added.');
            return true;
        } catch (error) {
            log(`Error creating button: ${error.message}`);
            return false;
        }
    }

    // --- Dragging Logic for Button ---
    let isDragging = false;
    let dragStartX, dragStartY;
    let btnStartX, btnStartY;
    let wasDragged = false;

    function makeDraggable(btn) {
        btn.addEventListener('mousedown', (e) => {
            if (e.button !== 0 || isSelecting) {
                return;
            }
            e.stopPropagation();

            isDragging = true;
            wasDragged = false;

            dragStartX = e.clientX;
            dragStartY = e.clientY;

            const rect = btn.getBoundingClientRect();
            btnStartX = rect.left;
            btnStartY = rect.top;

            document.addEventListener('mousemove', handleDragMove, { capture: true });
            document.addEventListener('mouseup', handleDragEnd, { capture: true });
        });
    }

    function handleDragMove(e) {
        if (!isDragging) return;

        if (!wasDragged) {
            const dx = Math.abs(e.clientX - dragStartX);
            const dy = Math.abs(e.clientY - dragStartY);
            if (dx > 5 || dy > 5) {
                wasDragged = true;
                copyBtn.classList.add('dragging');
                document.body.style.cursor = 'move';
            }
        }

        if (wasDragged) {
            e.stopPropagation();
            e.preventDefault();

            const deltaX = e.clientX - dragStartX;
            const deltaY = e.clientY - dragStartY;

            let newLeft = btnStartX + deltaX;
            let newTop = btnStartY + deltaY;

            const btnRect = copyBtn.getBoundingClientRect();
            const viewportWidth = window.innerWidth;
            const viewportHeight = window.innerHeight;

            if (newLeft < 0) newLeft = 0;
            else if (newLeft + btnRect.width > viewportWidth) newLeft = viewportWidth - btnRect.width;

            if (newTop < 0) newTop = 0;
            else if (newTop + btnRect.height > viewportHeight) newTop = viewportHeight - btnRect.height;

            copyBtn.style.left = `${newLeft}px`;
            copyBtn.style.top = `${newTop}px`;
            copyBtn.style.right = 'auto';
        }
    }

    function handleDragEnd(e) {
        if (!isDragging) return;

        if (wasDragged) {
            e.stopPropagation();
            e.preventDefault();

            const pos = { left: copyBtn.style.left, top: copyBtn.style.top };
            try {
                localStorage.setItem('markdown-copy-btn-pos', JSON.stringify(pos));
                debugLog(`Saved button position: ${JSON.stringify(pos)}`);
            } catch (err) {
                log(`Error saving button position: ${err.message}`);
            }
        }

        isDragging = false;
        copyBtn.classList.remove('dragging');
        if (isSelecting) {
            document.body.style.cursor = isDivMode ? 'pointer' : 'crosshair';
        } else {
            document.body.style.cursor = 'default';
        }

        document.removeEventListener('mousemove', handleDragMove, { capture: true });
        document.removeEventListener('mouseup', handleDragEnd, { capture: true });
    }


    // --- Event Listeners Setup ---
    function setupEventListeners() {
        if (!copyBtn) {
            log("Error: Button not found for adding listeners.");
            return;
        }

        copyBtn.addEventListener('click', handleButtonClick);
        document.addEventListener('mousedown', handleMouseDown, true);
        document.addEventListener('mousemove', handleMouseMove, true);
        document.addEventListener('mouseup', handleMouseUp, true);
        document.addEventListener('mouseover', handleMouseOverDiv);
        document.addEventListener('click', handleClickDiv, true);
        document.addEventListener('keydown', handleKeyDown);

        makeDraggable(copyBtn); // Make the button draggable

        debugLog('Event listeners added.');
    }

    // --- Button Click Logic ---
    function handleButtonClick(e) {
        if (wasDragged) {
            return;
        }
        e.stopPropagation();

        if (!isSelecting) {
            isSelecting = true;
            isDivMode = true;
            setButtonState(BUTTON_TEXT_SELECTING_DIV);
            document.body.style.cursor = 'pointer';
            log('Entered Div Selection Mode.');
        } else if (isDivMode) {
            isDivMode = false;
            setButtonState(BUTTON_TEXT_SELECTING_FREE);
            document.body.style.cursor = 'crosshair';
            log('Switched to Free Selection Mode.');
            removeDivHighlight();
        } else {
            resetSelectionState();
            log('Selection cancelled by button click.');
        }
    }

    // --- Mouse Event Handlers for Selection ---
    function handleMouseDown(e) {
        if (isSelecting && !isDivMode && e.button === 0) {
            e.preventDefault();
            e.stopPropagation();
            startX = e.pageX;
            startY = e.pageY;
            selectionBox = document.createElement('div');
            selectionBox.className = 'markdown-copy-selection-box';
            selectionBox.style.left = `${startX}px`;
            selectionBox.style.top = `${startY}px`;
            document.body.appendChild(selectionBox);
        }
    }

    function handleMouseMove(e) {
        if (selectionBox) {
            const currentX = e.pageX;
            const currentY = e.pageY;
            const width = Math.abs(currentX - startX);
            const height = Math.abs(currentY - startY);
            const left = Math.min(currentX, startX);
            const top = Math.min(currentY, startY);
            selectionBox.style.width = `${width}px`;
            selectionBox.style.height = `${height}px`;
            selectionBox.style.left = `${left}px`;
            selectionBox.style.top = `${top}px`;
        } else if (isDivMode && isSelecting) {
            handleMouseOverDiv(e);
        }
    }

    function handleMouseUp(e) {
        if (selectionBox) {
            const rect = selectionBox.getBoundingClientRect();
            document.body.removeChild(selectionBox);
            selectionBox = null;
            if (rect.width > 5 && rect.height > 5) {
                copyContentInRect(rect);
            }
            resetSelectionState();
        }
    }

    function handleMouseOverDiv(e) {
        if (!isDivMode || !isSelecting) return;
        const target = e.target;
        if (target === copyBtn || target.closest('.markdown-copy-btn')) return;

        if (highlightedDiv && highlightedDiv !== target) {
            removeDivHighlight();
        }
        if (target && target.nodeType === 1 && !target.classList.contains('markdown-copy-div-highlight')) {
            highlightedDiv = target;
            highlightedDiv.classList.add('markdown-copy-div-highlight');
        }
    }

    function handleClickDiv(e) {
        if (isDivMode && isSelecting && highlightedDiv) {
            e.preventDefault();
            e.stopPropagation();
            const divToCopy = highlightedDiv;
            removeDivHighlight();
            copyElementAsMarkdown(divToCopy);
            resetSelectionState();
        }
    }

    // --- Keyboard Event Handler ---
    function handleKeyDown(e) {
        if (e.key === 'Escape' && isSelecting) {
            resetSelectionState();
            log('Selection cancelled by ESC key.');
        }
    }

    // --- State Management ---
    function setButtonState(text, temporary = false, success = null) {
        if (messageTimeout) {
            clearTimeout(messageTimeout);
            messageTimeout = null;
        }
        copyBtn.textContent = text;
        copyBtn.classList.remove('mc-copied', 'mc-failed');
        if (success === true) copyBtn.classList.add('mc-copied');
        if (success === false) copyBtn.classList.add('mc-failed');

        if (temporary) {
            messageTimeout = setTimeout(() => {
                copyBtn.textContent = originalButtonText;
                copyBtn.classList.remove('mc-copied', 'mc-failed');
            }, TEMP_MESSAGE_DURATION);
        } else {
            originalButtonText = text;
        }
    }

    function resetSelectionState() {
        isSelecting = false;
        isDivMode = false;
        document.body.style.cursor = 'default';
        setButtonState(BUTTON_TEXT_DEFAULT);
        if (selectionBox) {
            document.body.removeChild(selectionBox);
            selectionBox = null;
        }
        removeDivHighlight();
    }

    function removeDivHighlight() {
        if (highlightedDiv) {
            highlightedDiv.classList.remove('markdown-copy-div-highlight');
            highlightedDiv = null;
        }
    }

    // --- Core Copying Logic ---
    async function copyContentInRect(rect) {
        try {
            const container = document.createElement('div');
            const elementsInRect = [];

            const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);
            while (walker.nextNode()) {
                const node = walker.currentNode;
                if (node.closest && node.closest('.markdown-copy-btn')) continue;
                if (isNodeVisible(node) && isNodeInRect(node, rect)) {
                    elementsInRect.push(node);
                }
            }

            if (elementsInRect.length === 0) {
                log('没有在选定区域找到可复制的内容。');
                return;
            }

            const topLevelElements = elementsInRect.filter(el => {
                let parent = el.parentElement;
                while (parent) {
                    if (elementsInRect.includes(parent)) {
                        return false;
                    }
                    parent = parent.parentElement;
                }
                return true;
            });

            topLevelElements.forEach(el => container.appendChild(el.cloneNode(true)));

            const htmlContent = container.innerHTML;
            debugLog(`复制的 HTML 内容: ${htmlContent}`);

            if (!htmlContent.trim()) {
                log('没有在选定区域找到可复制的内容。');
                return;
            }

            const markdown = turndownService.turndown(htmlContent);
            await GM_setClipboard(markdown, 'text');
            setButtonState(BUTTON_TEXT_COPIED, true, true);
            log('内容已复制为 Markdown。');
        } catch (error) {
            log(`复制失败: ${error.message}`);
            debugLog(`复制失败的错误: ${error.stack}`);
            setButtonState(BUTTON_TEXT_FAILED, true, false);
        }
    }

    async function copyElementAsMarkdown(element) {
        try {
            const markdown = turndownService.turndown(element);
            await GM_setClipboard(markdown, 'text');
            setButtonState(BUTTON_TEXT_COPIED, true, true);
            log('元素已复制为 Markdown。');
        } catch (error) {
            log(`复制失败: ${error.message}`);
            debugLog(`复制失败的元素: ${element.outerHTML}`);
            debugLog(`复制失败的错误: ${error.stack}`);
            setButtonState(BUTTON_TEXT_FAILED, true, false);
        }
    }

    // --- Utility Functions ---
    function isNodeInRect(node, rect) {
        const nodeRect = node.getBoundingClientRect();
        return (
            nodeRect.top < rect.bottom &&
            nodeRect.bottom > rect.top &&
            nodeRect.left < rect.right &&
            nodeRect.right > rect.left
        );
    }

    function isNodeVisible(node) {
        return !!(node.offsetWidth || node.offsetHeight || node.getClientRects().length);
    }

    // --- Script Entry Point ---
    function checkLibsReady(callback) {
        debugLog('正在检查 Turndown 库...');
        if (typeof TurndownService !== 'undefined' && typeof TurndownService === 'function') {
            debugLog('Turndown 库已就绪。');
            callback();
        } else {
            let attempts = 0;
            const interval = setInterval(() => {
                attempts++;
                if (typeof TurndownService !== 'undefined' && typeof TurndownService === 'function') {
                    clearInterval(interval);
                    debugLog(`Turndown 库在 ${attempts} 次尝试后加载。`);
                    callback();
                } else if (attempts > 20) { // Timeout after 2 seconds
                    clearInterval(interval);
                    log('错误:Turndown 库加载超时。请检查网络连接或脚本的 @require URL。');
                    if(copyBtn) { // Check if button exists before trying to update it
                        copyBtn.textContent = '库加载失败';
                        copyBtn.classList.add('mc-failed');
                        copyBtn.disabled = true;
                    } else { // If button doesn't exist yet, create it to show the error
                        createButton();
                        if(copyBtn){
                            copyBtn.textContent = '库加载失败';
                            copyBtn.classList.add('mc-failed');
                            copyBtn.disabled = true;
                        }
                    }
                }
            }, 100);
        }
    }

    onDOMReady(() => {
        checkLibsReady(initScript);
    });

})();