Page Text Downloader

Floating button to download page text, selected text, or clipboard. Click: download, Hold: copy, Shift+Click: clipboard.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Page Text Downloader
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      8.0
// @author       Piknockyou (vibe-coded)
// @license      AGPL-3.0
// @description  Floating button to download page text, selected text, or clipboard. Click: download, Hold: copy, Shift+Click: clipboard.
// @match        *://*/*
// @match        file:///*
// @icon         https://cdn.jsdelivr.net/gh/twitter/twemoji@latest/assets/svg/1f4c4.svg
// @grant        GM_download
// @grant        GM_setClipboard
// @grant        GM_getValue
// @grant        GM_setValue
// @run-at       document-idle
// ==/UserScript==

(function () {
    'use strict';

    //================================================================================
    // CONFIGURATION
    //================================================================================

    const CONFIG = {
        file: {
            defaultExtension: 'md',
            includeHeader: true,
            lastExtensionKey: 'ptd_last_extension'
        },

        button: {
            size: 24,

            iconStyle: {
                shadow: {
                    enabled: true,
                    blur: 2,
                    color: 'rgba(255, 255, 255, 0.8)'
                },
                background: {
                    enabled: true,
                    color: 'rgba(128, 128, 128, 0.25)',
                    borderRadius: '50%'
                }
            },

            position: {
                vertical: 'bottom',
                horizontal: 'right',
                offsetX: 1,
                offsetY: 1
            },

            opacity: {
                default: 0.15,
                hover: 1,
                active: 0.7
            },

            scale: {
                default: 1,
                hover: 1.1,
                active: 0.95
            },

            zIndex: 2147483647
        },

        timing: {
            copyHoldThreshold: 300,
            doubleClickThreshold: 350,
            hideTemporarilyDuration: 5000
        }
    };

    //================================================================================
    // GLOBAL STATE
    //================================================================================

    let shadowRoot = null;
    let notificationElement = null;
    let containerElement = null;
    let extensionModalElement = null;

    const STATE = {
        hidden: false
    };

    //================================================================================
    // HELPER: Get domain from URL
    //================================================================================

    function getDomain(url) {
        try {
            const hostname = new URL(url).hostname;
            return hostname.replace(/^www\./, '');
        } catch {
            return 'unknown';
        }
    }

    //================================================================================
    // HELPER: Sanitize filename (now appends domain)
    //================================================================================

    function sanitizeFilename(name, includeDomain = true) {
        let sanitized = name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_');
        sanitized = sanitized.replace(/_+/g, '_');
        sanitized = sanitized.replace(/^_+|_+$/g, '').trim();
        sanitized = sanitized.substring(0, 180);

        if (includeDomain) {
            const domain = getDomain(window.location.href);
            sanitized = sanitized + '_' + domain.replace(/\./g, '-');
        }

        return sanitized || 'downloaded_page_text';
    }

    //================================================================================
    // HELPER: Generate file header with metadata
    //================================================================================

    function generateFileHeader() {
        const url = window.location.href;
        const title = document.title || 'Untitled Page';
        const date = new Date().toISOString();
        const domain = getDomain(url);

        return [
            '<!--',
            `  Source: ${url}`,
            `  Title: ${title}`,
            `  Domain: ${domain}`,
            `  Downloaded: ${date}`,
            '-->',
            '',
            ''
        ].join('\n');
    }

    //================================================================================
    // HELPER: Last extension (GM storage)
    //================================================================================

    function getLastExtension() {
        try {
            return GM_getValue(CONFIG.file.lastExtensionKey, CONFIG.file.defaultExtension);
        } catch {
            return CONFIG.file.defaultExtension;
        }
    }

    function saveLastExtension(ext) {
        if (!ext) return;
        ext = ext.toLowerCase().replace(/^\./, '');
        GM_setValue(CONFIG.file.lastExtensionKey, ext);
    }

    //================================================================================
    // NOTIFICATIONS (inside Shadow DOM)
    //================================================================================

    function showNotification(message, type = 'info') {
        if (!shadowRoot || !notificationElement) return;

        notificationElement.classList.remove('show');
        notificationElement.textContent = message;
        notificationElement.setAttribute('data-type', type);

        void notificationElement.offsetWidth;
        notificationElement.classList.add('show');

        setTimeout(() => {
            notificationElement.classList.remove('show');
        }, 2500);
    }

    //================================================================================
    // EXTENSION PROMPT MODAL
    //================================================================================

    function showExtensionPrompt(callback) {
        if (!shadowRoot) return;

        // Build modal fresh each time using DOM APIs (Trusted Types safe)
        const modal = document.createElement('div');
        modal.className = 'ptd-ext-modal';

        const dialog = document.createElement('div');
        dialog.className = 'ptd-ext-dialog';

        const title = document.createElement('div');
        title.className = 'ptd-ext-title';
        title.textContent = '📄 File Extension';

        const input = document.createElement('input');
        input.type = 'text';
        input.className = 'ptd-ext-input';
        input.placeholder = 'md, txt, py, ps1...';
        input.spellcheck = false;
        input.autocomplete = 'off';
        input.value = getLastExtension();

        const hint = document.createElement('div');
        hint.className = 'ptd-ext-hint';
        hint.textContent = 'Enter ↵ to download · Esc to cancel';

        const actions = document.createElement('div');
        actions.className = 'ptd-ext-actions';

        const cancelBtn = document.createElement('button');
        cancelBtn.type = 'button';
        cancelBtn.className = 'ptd-ext-cancel';
        cancelBtn.textContent = 'Cancel';

        const okBtn = document.createElement('button');
        okBtn.type = 'button';
        okBtn.className = 'ptd-ext-ok';
        okBtn.textContent = 'Download';

        actions.appendChild(cancelBtn);
        actions.appendChild(okBtn);
        dialog.appendChild(title);
        dialog.appendChild(input);
        dialog.appendChild(hint);
        dialog.appendChild(actions);
        modal.appendChild(dialog);

        // Remove old modal if exists
        if (extensionModalElement && extensionModalElement.parentNode) {
            extensionModalElement.parentNode.removeChild(extensionModalElement);
        }
        extensionModalElement = modal;
        shadowRoot.appendChild(modal);

        let isResolved = false;

        const cleanup = () => {
            if (modal.parentNode) {
                modal.parentNode.removeChild(modal);
            }
            document.removeEventListener('keydown', globalKeyHandler, true);
        };

        const handleSubmit = () => {
            if (isResolved) return;
            isResolved = true;
            const ext = input.value.trim().replace(/^\./, '') || CONFIG.file.defaultExtension;
            saveLastExtension(ext);
            cleanup();
            callback(ext);
        };

        const handleCancel = () => {
            if (isResolved) return;
            isResolved = true;
            cleanup();
        };

        // Event handlers using addEventListener for reliability
        okBtn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            handleSubmit();
        }, true);

        cancelBtn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            handleCancel();
        }, true);

        input.addEventListener('keydown', (e) => {
            e.stopPropagation();
            if (e.key === 'Enter') {
                e.preventDefault();
                handleSubmit();
            } else if (e.key === 'Escape') {
                e.preventDefault();
                handleCancel();
            }
        }, true);

        // Prevent clicks on dialog from closing modal
        dialog.addEventListener('click', (e) => {
            e.stopPropagation();
        }, true);

        // Prevent mousedown from affecting focus
        dialog.addEventListener('mousedown', (e) => {
            if (e.target !== input) {
                e.preventDefault();
            }
        }, true);

        // Click on backdrop closes
        modal.addEventListener('click', (e) => {
            if (e.target === modal) {
                e.preventDefault();
                handleCancel();
            }
        }, true);

        // Global escape key handler
        const globalKeyHandler = (e) => {
            if (e.key === 'Escape') {
                e.preventDefault();
                e.stopPropagation();
                handleCancel();
            }
        };
        document.addEventListener('keydown', globalKeyHandler, true);

        // Show modal and focus
        modal.classList.add('visible');

        // Double RAF to ensure rendering is complete
        requestAnimationFrame(() => {
            requestAnimationFrame(() => {
                input.focus();
                input.select();
            });
        });
    }

    //================================================================================
    // MAIN FUNCTIONS
    //================================================================================

    function getPageText() {
        const textContent = document.body.innerText;
        if (!textContent || textContent.trim() === "") {
            return null;
        }
        return textContent;
    }

    function getSelectedText() {
        const selection = window.getSelection();
        if (selection && selection.toString().trim()) {
            return selection.toString();
        }
        return null;
    }

    async function getClipboardText() {
        try {
            return await navigator.clipboard.readText();
        } catch {
            showNotification('Clipboard access denied', 'error');
            return null;
        }
    }

    function copyToClipboard() {
        const textContent = getPageText();

        if (!textContent) {
            showNotification('No visible text content found', 'error');
            return;
        }

        if (typeof GM_setClipboard === 'function') {
            GM_setClipboard(textContent, 'text');
            showNotification('✓ Copied to clipboard', 'success');
        } else {
            navigator.clipboard.writeText(textContent)
                .then(() => showNotification('✓ Copied to clipboard', 'success'))
                .catch(() => showNotification('Failed to copy', 'error'));
        }
    }

    function downloadContent(content, ext, filenameBase = null, includeHeader = true) {
        if (!content) {
            showNotification('No content to download', 'error');
            return;
        }

        const pageTitle = filenameBase || document.title || 'Untitled Page';
        const filename = sanitizeFilename(pageTitle) + '.' + ext;

        let finalContent = content;
        if (includeHeader && CONFIG.file.includeHeader) {
            finalContent = generateFileHeader() + content;
        }

        try {
            GM_download({
                url: 'data:text/plain;charset=utf-8,' + encodeURIComponent(finalContent),
                name: filename,
                saveAs: true,
                onerror: function (errorDetails) {
                    console.error("Download Error:", errorDetails);
                    fallbackDownload(finalContent, filename);
                }
            });
            showNotification('⬇ Downloading ' + filename, 'info');
        } catch (e) {
            fallbackDownload(finalContent, filename);
        }
    }

    function downloadPageText() {
        const textContent = getPageText();
        if (!textContent) {
            showNotification('No visible text content found', 'error');
            return;
        }
        downloadContent(textContent, CONFIG.file.defaultExtension);
    }

    async function downloadClipboard() {
        const clipboardText = await getClipboardText();
        if (!clipboardText || !clipboardText.trim()) {
            showNotification('Clipboard is empty', 'error');
            return;
        }
        showExtensionPrompt((ext) => {
            // No header for clipboard content
            downloadContent(clipboardText, ext, 'clipboard_' + (document.title || 'page'), false);
        });
    }

    function fallbackDownload(content, filename) {
        try {
            const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            document.body.removeChild(a);
            URL.revokeObjectURL(url);
            showNotification('⬇ Downloading...', 'info');
        } catch (fallbackError) {
            console.error("Fallback failed:", fallbackError);
            showNotification('Download failed', 'error');
        }
    }

    //================================================================================
    // VISIBILITY LOGIC
    //================================================================================

    function updateVisibility() {
        if (!containerElement) return;
        containerElement.classList.toggle('hidden', STATE.hidden);
    }

    //================================================================================
    // STYLES FOR SHADOW DOM
    //================================================================================

    function getStyles() {
        const cfg = CONFIG.button;
        const pos = cfg.position;
        const iconSize = cfg.size - 4;

        return `
            :host { all: initial; }
            * { box-sizing: border-box; }

            .ptd-container {
                position: fixed;
                ${pos.vertical}: ${pos.offsetY}px;
                ${pos.horizontal}: ${pos.offsetX}px;
                z-index: ${cfg.zIndex};
                pointer-events: auto;
                user-select: none;
            }

            .ptd-container.hidden { display: none !important; }

            .ptd-btn {
                position: relative;
                width: ${cfg.size}px;
                height: ${cfg.size}px;
                background: transparent;
                border: none;
                cursor: pointer;
                opacity: ${cfg.opacity.default};
                display: flex;
                align-items: center;
                justify-content: center;
                padding: 0;
                margin: 0;
                transform: scale(${cfg.scale.default});
                transition: opacity 0.3s, transform 0.2s;
                touch-action: manipulation;
                -webkit-tap-highlight-color: transparent;
            }

            .ptd-btn[data-hover="true"] {
                opacity: ${cfg.opacity.hover};
                transform: scale(${cfg.scale.hover});
            }

            .ptd-btn[data-active="true"] {
                opacity: ${cfg.opacity.active};
                transform: scale(${cfg.scale.active});
            }

            .ptd-icon-container {
                display: flex;
                align-items: center;
                justify-content: center;
                width: ${cfg.size}px;
                height: ${cfg.size}px;
                border-radius: ${cfg.iconStyle.background.enabled ? cfg.iconStyle.background.borderRadius : '0'};
                background-color: ${cfg.iconStyle.background.enabled ? cfg.iconStyle.background.color : 'transparent'};
                pointer-events: none;
            }

            .ptd-icon {
                width: ${iconSize}px;
                height: ${iconSize}px;
                display: block;
                pointer-events: none;
                ${cfg.iconStyle.shadow.enabled ? `
                    filter: drop-shadow(0 0 ${cfg.iconStyle.shadow.blur}px ${cfg.iconStyle.shadow.color})
                            drop-shadow(0 0 ${cfg.iconStyle.shadow.blur * 0.5}px ${cfg.iconStyle.shadow.color});
                ` : ''}
            }

            .ptd-progress-ring {
                position: absolute;
                top: -3px;
                left: -3px;
                width: ${cfg.size + 6}px;
                height: ${cfg.size + 6}px;
                transform: rotate(-90deg);
                pointer-events: none;
                opacity: 0;
            }

            .ptd-progress-ring.visible { opacity: 1; }

            .ptd-notification {
                position: fixed;
                ${pos.vertical}: ${pos.offsetY + cfg.size + 10}px;
                ${pos.horizontal}: ${pos.offsetX}px;
                padding: 8px 16px;
                color: white;
                border-radius: 6px;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                font-size: 13px;
                font-weight: 500;
                z-index: ${cfg.zIndex - 1};
                box-shadow: 0 3px 12px rgba(0,0,0,0.25);
                opacity: 0;
                transform: translateY(10px);
                transition: opacity 0.3s, transform 0.3s;
                pointer-events: none;
            }

            .ptd-notification.show {
                opacity: 1;
                transform: translateY(0);
            }

            .ptd-notification[data-type="success"] { background: #4CAF50; border: 1px solid #388E3C; }
            .ptd-notification[data-type="error"] { background: #f44336; border: 1px solid #c62828; }
            .ptd-notification[data-type="info"] { background: #2196F3; border: 1px solid #1565C0; }
            .ptd-notification[data-type="warning"] { background: #FF9800; border: 1px solid #EF6C00; }

            /* Extension prompt modal - Dark Mode */
            .ptd-ext-modal {
                position: fixed;
                top: 0;
                left: 0;
                right: 0;
                bottom: 0;
                width: 100vw;
                height: 100vh;
                background: rgba(0, 0, 0, 0.7);
                display: none;
                align-items: center;
                justify-content: center;
                z-index: ${cfg.zIndex + 10};
                pointer-events: auto;
                user-select: none;
            }

            .ptd-ext-modal.visible { display: flex; }

            .ptd-ext-dialog {
                background: #1e1e1e;
                border: 1px solid #444;
                border-radius: 12px;
                padding: 20px 24px;
                min-width: 280px;
                max-width: 320px;
                box-shadow: 0 16px 48px rgba(0,0,0,0.6);
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                pointer-events: auto;
            }

            .ptd-ext-title {
                font-size: 15px;
                font-weight: 600;
                color: #e0e0e0;
                margin: 0 0 16px 0;
                user-select: none;
            }

            .ptd-ext-input {
                display: block;
                width: 100%;
                padding: 12px 14px;
                background: #2a2a2a;
                border: 2px solid #505050;
                border-radius: 8px;
                font-size: 15px;
                font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
                color: #fff;
                outline: none;
                transition: border-color 0.2s, box-shadow 0.2s;
                box-sizing: border-box;
                -webkit-appearance: none;
                appearance: none;
                user-select: text;
                pointer-events: auto;
            }

            .ptd-ext-input:focus {
                border-color: #4a9eff;
                box-shadow: 0 0 0 3px rgba(74, 158, 255, 0.25);
            }

            .ptd-ext-input::placeholder { color: #666; }
            .ptd-ext-input::selection { background: #4a9eff; color: #fff; }

            .ptd-ext-hint {
                font-size: 11px;
                color: #888;
                margin: 12px 0 0 0;
                text-align: center;
                user-select: none;
            }

            .ptd-ext-actions {
                display: flex;
                gap: 10px;
                margin-top: 18px;
            }

            .ptd-ext-ok, .ptd-ext-cancel {
                flex: 1;
                padding: 11px 16px;
                border: none;
                border-radius: 8px;
                font-size: 14px;
                font-weight: 500;
                cursor: pointer;
                transition: background 0.15s, transform 0.1s;
                pointer-events: auto;
                user-select: none;
                -webkit-appearance: none;
                appearance: none;
            }

            .ptd-ext-ok {
                background: #4a9eff;
                color: #fff;
            }
            .ptd-ext-ok:hover { background: #5aafff; }
            .ptd-ext-ok:active { background: #3a8eef; transform: scale(0.98); }

            .ptd-ext-cancel {
                background: #3a3a3a;
                color: #ccc;
            }
            .ptd-ext-cancel:hover { background: #4a4a4a; }
            .ptd-ext-cancel:active { background: #333; transform: scale(0.98); }

            /* Tooltip container */
            .ptd-tooltip {
                position: absolute;

                /* Horizontal: appear on opposite side of screen edge */
                ${pos.horizontal === 'right' ? 'right' : 'left'}: ${cfg.size + 8}px;

                /* Vertical: anchor to opposite of screen edge */
                ${pos.vertical === 'bottom' ? 'bottom' : 'top'}: 0;

                background: rgba(20, 20, 20, 0.95);
                color: #fff;
                padding: 12px 14px;
                border-radius: 8px;
                font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
                font-size: 12px;
                pointer-events: none;
                opacity: 0;
                visibility: hidden;
                transition: opacity 0.15s ease, visibility 0.15s ease;
                box-shadow: 0 4px 16px rgba(0,0,0,0.4);
                z-index: ${cfg.zIndex + 1};
                border: 1px solid #333;
                white-space: nowrap;
            }

            .ptd-btn[data-hover="true"] .ptd-tooltip {
                opacity: 1;
                visibility: visible;
            }

            .ptd-tooltip-title {
                font-weight: 600;
                font-size: 13px;
                margin-bottom: 8px;
                padding-bottom: 8px;
                border-bottom: 1px solid #444;
            }

            .ptd-tooltip-grid {
                display: grid;
                grid-template-columns: auto auto;
                gap: 6px 16px;
                align-items: center;
            }

            .ptd-tooltip-action {
                color: #aaa;
                font-size: 11px;
            }

            .ptd-tooltip-desc {
                color: #fff;
                font-size: 12px;
            }
        `;
    }

    //================================================================================
    // FLOATING BUTTON (Shadow DOM)
    //================================================================================

    function addFloatingDownloadButton() {
        if (!document.body) return;

        const shadowHost = document.createElement('div');
        shadowHost.id = 'page-text-downloader-host';
        Object.assign(shadowHost.style, {
            position: 'fixed',
            top: '0',
            left: '0',
            width: '0',
            height: '0',
            overflow: 'visible',
            zIndex: CONFIG.button.zIndex.toString(),
            pointerEvents: 'none',
            userSelect: 'none'
        });

        shadowRoot = shadowHost.attachShadow({ mode: 'closed' });

        const style = document.createElement('style');
        style.textContent = getStyles();
        shadowRoot.appendChild(style);

        const container = document.createElement('div');
        container.className = 'ptd-container';
        containerElement = container;

        const btn = document.createElement('div');
        btn.className = 'ptd-btn';

        // Build tooltip with grid layout
        const tooltip = document.createElement('div');
        tooltip.className = 'ptd-tooltip';

        const tooltipTitle = document.createElement('div');
        tooltipTitle.className = 'ptd-tooltip-title';
        tooltipTitle.textContent = '📄 Page Text Downloader';

        const tooltipGrid = document.createElement('div');
        tooltipGrid.className = 'ptd-tooltip-grid';

        const tooltipItems = [
            ['🖱️ Click', 'Download page (.md)'],
            ['✂️ Select + Click', 'Download selection'],
            ['⇧ Shift + Click', 'Download clipboard'],
            ['⏱️ Hold', 'Copy to clipboard'],
            ['👆 Double-click', 'Hide 5s']
        ];

        tooltipItems.forEach(([action, desc]) => {
            const actionEl = document.createElement('span');
            actionEl.className = 'ptd-tooltip-action';
            actionEl.textContent = action;

            const descEl = document.createElement('span');
            descEl.className = 'ptd-tooltip-desc';
            descEl.textContent = desc;

            tooltipGrid.appendChild(actionEl);
            tooltipGrid.appendChild(descEl);
        });

        tooltip.appendChild(tooltipTitle);
        tooltip.appendChild(tooltipGrid);
        btn.appendChild(tooltip);

        const iconContainer = document.createElement('div');
        iconContainer.className = 'ptd-icon-container';

        const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        iconSvg.setAttribute('class', 'ptd-icon');
        iconSvg.setAttribute('viewBox', '0 0 24 24');
        iconSvg.setAttribute('fill', 'none');
        iconSvg.setAttribute('stroke', '#555');
        iconSvg.setAttribute('stroke-width', '2');
        iconSvg.setAttribute('stroke-linecap', 'round');
        iconSvg.setAttribute('stroke-linejoin', 'round');

        const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path1.setAttribute('d', 'M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z');

        const polyline1 = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
        polyline1.setAttribute('points', '14 2 14 8 20 8');

        const line1 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
        line1.setAttribute('x1', '16');
        line1.setAttribute('y1', '13');
        line1.setAttribute('x2', '8');
        line1.setAttribute('y2', '13');

        const line2 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
        line2.setAttribute('x1', '16');
        line2.setAttribute('y1', '17');
        line2.setAttribute('x2', '8');
        line2.setAttribute('y2', '17');

        const polyline2 = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
        polyline2.setAttribute('points', '10 9 9 9 8 9');

        iconSvg.appendChild(path1);
        iconSvg.appendChild(polyline1);
        iconSvg.appendChild(line1);
        iconSvg.appendChild(line2);
        iconSvg.appendChild(polyline2);

        iconContainer.appendChild(iconSvg);
        btn.appendChild(iconContainer);

        const progressRing = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        progressRing.setAttribute('class', 'ptd-progress-ring');
        progressRing.setAttribute('viewBox', '0 0 36 36');

        const progressCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
        progressCircle.setAttribute('cx', '18');
        progressCircle.setAttribute('cy', '18');
        progressCircle.setAttribute('r', '16');
        progressCircle.setAttribute('fill', 'none');
        progressCircle.setAttribute('stroke', '#4CAF50');
        progressCircle.setAttribute('stroke-width', '2');
        progressCircle.setAttribute('stroke-dasharray', '100.53');
        progressCircle.setAttribute('stroke-dashoffset', '100.53');

        progressRing.appendChild(progressCircle);
        btn.appendChild(progressRing);

        container.appendChild(btn);
        shadowRoot.appendChild(container);

        notificationElement = document.createElement('div');
        notificationElement.className = 'ptd-notification';
        notificationElement.setAttribute('data-type', 'info');
        shadowRoot.appendChild(notificationElement);

        // Extension modal will be created on demand
        extensionModalElement = null;

        document.body.appendChild(shadowHost);

        //================================================================================
        // STATE & HELPERS
        //================================================================================

        let pressTimer = null;
        let isLongPress = false;
        let lastClickTime = 0;
        let hideTimeout = null;
        let isHovering = false;
        let cachedRect = null;

        const setHover = (val) => {
            if (isHovering === val) return;
            isHovering = val;
            btn.setAttribute('data-hover', val ? 'true' : 'false');
        };

        const setActive = (val) => {
            btn.setAttribute('data-active', val ? 'true' : 'false');
        };

        const resetProgressRing = () => {
            progressRing.classList.remove('visible');
            progressCircle.setAttribute('stroke-dashoffset', '100.53');
        };

        // Robust coordinate check to fix "stuck" highlight on sites like Instagram
        const onDocumentMouseMove = (e) => {
            if (!cachedRect) return;
            const x = e.clientX, y = e.clientY, t = 2;
            if (x < cachedRect.left - t || x > cachedRect.right + t ||
                y < cachedRect.top - t || y > cachedRect.bottom + t) {
                setHover(false);
                stopHoverTracking();
            }
        };

        const startHoverTracking = () => {
            cachedRect = btn.getBoundingClientRect();
            document.addEventListener('mousemove', onDocumentMouseMove, { capture: true, passive: true });
        };

        const stopHoverTracking = () => {
            cachedRect = null;
            document.removeEventListener('mousemove', onDocumentMouseMove, { capture: true });
        };

        //================================================================================
        // MOUSE EVENTS
        //================================================================================

        // Hover events (Pure Event-Based: Zero timers, robust cross-site)
        btn.addEventListener('mouseenter', () => {
            setHover(true);
            startHoverTracking();
        });

        btn.addEventListener('mouseleave', () => {
            setHover(false);
            stopHoverTracking();
            if (pressTimer) {
                clearTimeout(pressTimer);
                pressTimer = null;
                setActive(false);
                resetProgressRing();
            }
        });

        // Pointerleave fallback for modern touch/hybrid support
        btn.addEventListener('pointerleave', () => {
            setHover(false);
            stopHoverTracking();
        });

        btn.addEventListener('mousedown', (e) => {
            if (e.button !== 0) return;
            e.preventDefault();

            isLongPress = false;
            setActive(true);
            progressRing.classList.add('visible');

            // Purple ring for copy action
            progressCircle.setAttribute('stroke', '#9C27B0');

            const circumference = 100.53;
            const duration = CONFIG.timing.copyHoldThreshold;
            const startTime = Date.now();

            const animateProgress = () => {
                if (!pressTimer) return;
                const elapsed = Date.now() - startTime;
                const progress = Math.min(elapsed / duration, 1);
                progressCircle.setAttribute('stroke-dashoffset', (circumference * (1 - progress)).toString());
                if (progress < 1) requestAnimationFrame(animateProgress);
            };

            requestAnimationFrame(animateProgress);

            pressTimer = setTimeout(() => {
                isLongPress = true;
                pressTimer = null;
                if (navigator.vibrate) navigator.vibrate(50);
                resetProgressRing();
                setActive(false);
                copyToClipboard();
            }, CONFIG.timing.copyHoldThreshold);
        });

        btn.addEventListener('mouseup', (e) => {
            if (e.button !== 0) return;

            const wasShiftHeld = e.shiftKey;
            const selectedText = getSelectedText();

            clearTimeout(pressTimer);
            pressTimer = null;
            resetProgressRing();
            setActive(false);

            if (isLongPress) {
                isLongPress = false;
                return;
            }

            const now = Date.now();
            const timeSinceLastClick = now - lastClickTime;
            lastClickTime = now;

            // Double-click: hide temporarily
            if (timeSinceLastClick < CONFIG.timing.doubleClickThreshold) {
                window.getSelection?.().removeAllRanges();
                STATE.hidden = true;
                stopHoverTracking();
                setHover(false);
                updateVisibility();

                if (hideTimeout) clearTimeout(hideTimeout);
                hideTimeout = setTimeout(() => {
                    STATE.hidden = false;
                    updateVisibility();
                }, CONFIG.timing.hideTemporarilyDuration);
                return;
            }

            // Shift+Click: download clipboard with prompt
            if (wasShiftHeld) {
                downloadClipboard();
                return;
            }

            // Click with selection: download selection with prompt
            if (selectedText) {
                showExtensionPrompt((ext) => {
                    downloadContent(selectedText, ext, 'selection_' + (document.title || 'page'), false);
                });
                return;
            }

            // Plain click: download page as md
            downloadPageText();
        });

        //================================================================================
        // TOUCH EVENTS
        //================================================================================

        btn.addEventListener('touchstart', (e) => {
            if (e.touches.length !== 1) return;
            e.preventDefault();

            isLongPress = false;
            setActive(true);
            progressRing.classList.add('visible');

            // Purple ring for copy action
            progressCircle.setAttribute('stroke', '#9C27B0');

            const circumference = 100.53;
            const duration = CONFIG.timing.copyHoldThreshold;
            const startTime = Date.now();

            const animateProgress = () => {
                if (!pressTimer) return;
                const elapsed = Date.now() - startTime;
                const progress = Math.min(elapsed / duration, 1);
                progressCircle.setAttribute('stroke-dashoffset', (circumference * (1 - progress)).toString());
                if (progress < 1) requestAnimationFrame(animateProgress);
            };

            requestAnimationFrame(animateProgress);

            pressTimer = setTimeout(() => {
                isLongPress = true;
                pressTimer = null;
                if (navigator.vibrate) navigator.vibrate(50);
                resetProgressRing();
                setActive(false);
                copyToClipboard();
            }, CONFIG.timing.copyHoldThreshold);
        }, { passive: false });

        btn.addEventListener('touchend', (e) => {
            e.preventDefault();

            const selectedText = getSelectedText();

            clearTimeout(pressTimer);
            pressTimer = null;
            resetProgressRing();
            setActive(false);

            if (isLongPress) {
                isLongPress = false;
                return;
            }

            const now = Date.now();
            const timeSinceLastClick = now - lastClickTime;
            lastClickTime = now;

            // Double-tap: hide temporarily
            if (timeSinceLastClick < CONFIG.timing.doubleClickThreshold) {
                STATE.hidden = true;
                updateVisibility();

                if (hideTimeout) clearTimeout(hideTimeout);
                hideTimeout = setTimeout(() => {
                    STATE.hidden = false;
                    updateVisibility();
                }, CONFIG.timing.hideTemporarilyDuration);
                return;
            }

            // Tap with selection: download selection with prompt
            if (selectedText) {
                showExtensionPrompt((ext) => {
                    downloadContent(selectedText, ext, 'selection_' + (document.title || 'page'), false);
                });
                return;
            }

            // Plain tap: download page as md
            downloadPageText();
        }, { passive: false });

        btn.addEventListener('touchcancel', () => {
            clearTimeout(pressTimer);
            pressTimer = null;
            isLongPress = false;
            resetProgressRing();
            setActive(false);
        });

        btn.addEventListener('contextmenu', (e) => e.preventDefault());

        //================================================================================
        // CLEANUP
        //================================================================================

        window.addEventListener('beforeunload', () => {
            stopHoverTracking();
        });

        updateVisibility();

        console.log('[Page Text Downloader] Button initialized');
    }

    //================================================================================
    // INIT
    //================================================================================

    if (document.body) {
        addFloatingDownloadButton();
    } else {
        document.addEventListener('DOMContentLoaded', addFloatingDownloadButton);
    }

})();