Page Text Downloader

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला 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);
    }

})();