Greasy Fork - Copy Code Button

Adds a split Copier button on both the script Info page (next to the install button) and the Code page (above the code block). Copy to clipboard or download as .js / .txt / .md. Includes Ctrl+Shift+C shortcut and live line/char stats.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Greasy Fork - Copy Code Button
// @namespace    https://greasyfork.org/
// @version      3.0
// @description  Adds a split Copier button on both the script Info page (next to the install button) and the Code page (above the code block). Copy to clipboard or download as .js / .txt / .md. Includes Ctrl+Shift+C shortcut and live line/char stats.
// @author       achma, with claude-AI
// @license MIT
// @match        https://greasyfork.org/*/scripts/*/code
// @match        https://greasyfork.org/scripts/*/code
// @match        https://greasyfork.org/*/scripts/*
// @match        https://greasyfork.org/scripts/*
// @icon         https://greasyfork.org/vite/assets/blacklogo96-CxYTSM_T.png
// @grant        GM_xmlhttpRequest
// @connect      update.greasyfork.org
// @run-at       document-idle
// ==/UserScript==

(() => {
    'use strict';

    // ─── Page detection ───────────────────────────────────────────────────────────

    const path = window.location.pathname;
    const IS_CODE_PAGE = /\/scripts\/[^/]+\/code$/.test(path);
    const IS_INFO_PAGE = /\/scripts\/[^/]+$/.test(path) && !IS_CODE_PAGE;

    // Only run on the two target page types
    if (!IS_CODE_PAGE && !IS_INFO_PAGE) return;

    // ─── Shared styles ────────────────────────────────────────────────────────────

    const style = document.createElement('style');
    style.textContent = `
        /* ── Split button wrapper ── */
        .gf-split-wrap {
            display: inline-flex;
            align-items: stretch;
            border-radius: 7px;
            position: relative;
            box-shadow: 0 2px 8px rgba(0,0,0,0.22);
            vertical-align: middle;
        }

        /* ── Main copy button ── */
        .gf-copy-btn {
            display: inline-flex;
            align-items: center;
            gap: 7px;
            padding: 7px 14px;
            background: #23272e;
            color: #f3f4f6;
            border: 1.5px solid #3a3f4a;
            border-right: none;
            border-radius: 7px 0 0 7px;
            font-size: 13px;
            font-weight: 600;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            cursor: pointer;
            transition: background 0.15s, color 0.15s;
            user-select: none;
            line-height: 1;
            white-space: nowrap;
        }
        .gf-copy-btn:hover { background: #2d3340; color: #fff; }
        .gf-copy-btn.gf-copied  { background: #10b981; border-color: #059669; color: #fff; }
        .gf-copy-btn.gf-error   { background: #ef4444; border-color: #dc2626; color: #fff; }
        .gf-copy-btn.gf-loading { background: #374151; border-color: #4b5563; color: #9ca3af; cursor: wait; }

        /* ── Divider ── */
        .gf-btn-divider {
            width: 1px;
            background: #3a3f4a;
            align-self: stretch;
            flex-shrink: 0;
        }

        /* ── Chevron button ── */
        .gf-chevron-btn {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            padding: 7px 9px;
            background: #23272e;
            color: #9ca3af;
            border: 1.5px solid #3a3f4a;
            border-left: none;
            border-radius: 0 7px 7px 0;
            cursor: pointer;
            transition: background 0.15s, color 0.15s;
            user-select: none;
        }
        .gf-chevron-btn:hover { background: #2d3340; color: #f3f4f6; }
        .gf-chevron-btn svg {
            transition: transform 0.2s cubic-bezier(0.4,0,0.2,1);
            display: block;
        }
        .gf-chevron-btn.open svg { transform: rotate(180deg); }

        /* ── Dropdown ── */
        .gf-dropdown {
            display: none;
            position: absolute;
            top: calc(100% + 6px);
            left: 0;
            min-width: 215px;
            background: #1c2028;
            border: 1.5px solid #3a3f4a;
            border-radius: 8px;
            box-shadow: 0 8px 28px rgba(0,0,0,0.40);
            z-index: 999999;
            overflow: hidden;
            animation: gf-drop-in 0.15s cubic-bezier(0.4,0,0.2,1);
        }
        .gf-dropdown.open { display: block; }

        @keyframes gf-drop-in {
            from { opacity: 0; transform: translateY(-6px) scale(0.98); }
            to   { opacity: 1; transform: translateY(0)   scale(1);    }
        }

        .gf-drop-header {
            padding: 9px 14px 5px;
            font-size: 10px;
            font-weight: 700;
            letter-spacing: 0.09em;
            text-transform: uppercase;
            color: #6b7280;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            user-select: none;
        }
        .gf-drop-divider { height: 1px; background: #2d3340; margin: 3px 10px; }

        .gf-drop-item {
            display: flex;
            align-items: center;
            gap: 10px;
            padding: 9px 14px;
            font-size: 13px;
            font-weight: 500;
            color: #d1d5db;
            cursor: pointer;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            transition: background 0.12s, color 0.12s;
            user-select: none;
        }
        .gf-drop-item:hover { background: #2d3340; color: #fff; }
        .gf-drop-item:last-child { margin-bottom: 5px; }
        .gf-drop-item.disabled { opacity: 0.45; cursor: wait; pointer-events: none; }

        .gf-drop-badge {
            display: inline-flex;
            align-items: center;
            justify-content: center;
            min-width: 34px;
            padding: 2px 7px;
            background: #2d3340;
            color: #9ca3af;
            border-radius: 4px;
            font-size: 11px;
            font-weight: 700;
            font-family: monospace;
            transition: background 0.12s, color 0.12s;
            flex-shrink: 0;
        }
        .gf-drop-item:hover .gf-drop-badge { background: #3b82f6; color: #fff; }

        /* ── Code page extras ── */
        #gf-copy-toolbar {
            display: flex;
            align-items: center;
            flex-wrap: wrap;
            gap: 10px;
            margin-bottom: 10px;
        }

        #gf-code-stats {
            font-size: 12px;
            color: #6b7280;
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            user-select: none;
            display: flex;
            align-items: center;
            gap: 6px;
        }
        #gf-shortcut-hint {
            font-size: 11px;
            color: #9ca3af;
            font-family: monospace;
            background: #f3f4f6;
            border: 1px solid #e5e7eb;
            border-radius: 4px;
            padding: 2px 7px;
            user-select: none;
        }

        /* ── Info page: wrapper to align with install button ── */
        #gf-info-copier-wrap {
            display: inline-flex;
            align-items: center;
            gap: 8px;
            margin-left: 10px;
            vertical-align: middle;
        }

        @media (prefers-color-scheme: dark) {
            #gf-code-stats   { color: #9ca3af; }
            #gf-shortcut-hint { background: #2d3340; border-color: #3a3f4a; color: #9ca3af; }
        }
        html.dark #gf-code-stats   { color: #9ca3af; }
        html.dark #gf-shortcut-hint { background: #2d3340; border-color: #3a3f4a; color: #9ca3af; }
    `;
    document.head.appendChild(style);

    // ─── SVG Icons ────────────────────────────────────────────────────────────────

    const ICON_COPY = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
        <rect x="9" y="9" width="13" height="13" rx="2"/>
        <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
    </svg>`;

    const ICON_CHECK = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
        <polyline points="20 6 9 17 4 12"/>
    </svg>`;

    const ICON_ERROR = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
        <circle cx="12" cy="12" r="10"/>
        <line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
    </svg>`;

    const ICON_SPINNER = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"
        style="animation: gf-spin 0.8s linear infinite;">
        <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
    </svg>`;

    const ICON_CHEVRON = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
        <polyline points="6 9 12 15 18 9"/>
    </svg>`;

    const ICON_DOWNLOAD = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"
        fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
        <polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
    </svg>`;

    // Inject spinner keyframe
    const spinStyle = document.createElement('style');
    spinStyle.textContent = `@keyframes gf-spin { to { transform: rotate(360deg); } }`;
    document.head.appendChild(spinStyle);

    // ─── Helpers ──────────────────────────────────────────────────────────────────

    function isMac() { return /Mac|iPhone|iPod|iPad/.test(navigator.platform); }
    function modKey() { return isMac() ? '⌘' : 'Ctrl'; }
    function fmt(n)   { return n.toLocaleString(); }

    function getScriptName() {
        const h2 = document.querySelector('#script-info h2');
        const raw = h2
            ? h2.textContent.trim()
            : document.title.replace(/\s*[-|].*$/, '').trim();
        return raw.replace(/[^\w\s\-().]/g, '').replace(/\s+/g, '_').substring(0, 80) || 'script';
    }

    // ─── Get code: two strategies ─────────────────────────────────────────────────

    // Strategy A — from the DOM (code page only)
    function getCodeFromDOM() {
        const pre = document.querySelector('.code-container pre');
        return pre ? (pre.innerText || pre.textContent || null) : null;
    }

    // Strategy B — fetch raw .user.js from install link URL (info page)
    function fetchCodeFromInstallLink() {
        return new Promise((resolve, reject) => {
            const installLink = document.querySelector('a.install-link[href]');
            if (!installLink) return reject(new Error('No install link found'));

            const url = installLink.href; // https://update.greasyfork.org/scripts/…user.js
            GM_xmlhttpRequest({
                method: 'GET',
                url,
                onload(res) {
                    if (res.status === 200) resolve(res.responseText);
                    else reject(new Error(`HTTP ${res.status}`));
                },
                onerror(err) { reject(err); }
            });
        });
    }

    // Unified getter — returns a Promise<string>
    async function getCode() {
        if (IS_CODE_PAGE) {
            const dom = getCodeFromDOM();
            if (dom) return dom;
        }
        return fetchCodeFromInstallLink();
    }

    // ─── Download ─────────────────────────────────────────────────────────────────

    function triggerDownload(content, filename, mime) {
        const blob = new Blob([content], { type: mime });
        const url  = URL.createObjectURL(blob);
        const a    = Object.assign(document.createElement('a'), { href: url, download: filename });
        document.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(() => URL.revokeObjectURL(url), 1000);
    }

    async function downloadAs(ext, dropdownItems) {
        // Disable items while fetching
        dropdownItems.forEach(i => i.classList.add('disabled'));
        try {
            const code = await getCode();
            const name = getScriptName();
            if (ext === 'js')  triggerDownload(code, `${name}.js`,  'text/javascript');
            if (ext === 'txt') triggerDownload(code, `${name}.txt`, 'text/plain');
            if (ext === 'md')  triggerDownload(
                `# ${name.replace(/_/g, ' ')}\n\n\`\`\`javascript\n${code}\n\`\`\`\n`,
                `${name}.md`, 'text/markdown'
            );
        } catch(e) {
            console.error('[GF Copier] Download failed:', e);
        } finally {
            dropdownItems.forEach(i => i.classList.remove('disabled'));
        }
    }

    // ─── Copy logic ───────────────────────────────────────────────────────────────

    let resetTimer = null;

    function setButtonState(btn, state, label) {
        clearTimeout(resetTimer);
        btn.classList.remove('gf-copied', 'gf-error', 'gf-loading');
        if (state === 'success') { btn.classList.add('gf-copied');  btn.innerHTML = `${ICON_CHECK}<span>${label}</span>`; }
        if (state === 'error')   { btn.classList.add('gf-error');   btn.innerHTML = `${ICON_ERROR}<span>${label}</span>`; }
        if (state === 'loading') { btn.classList.add('gf-loading'); btn.innerHTML = `${ICON_SPINNER}<span>${label}</span>`; return; }
        resetTimer = setTimeout(() => {
            btn.classList.remove('gf-copied', 'gf-error');
            btn.innerHTML = `${ICON_COPY}<span>Copier</span>`;
        }, 2500);
    }

    async function copyCode(btn) {
        setButtonState(btn, 'loading', 'Fetching…');
        let code;
        try {
            code = await getCode();
        } catch(e) {
            setButtonState(btn, 'error', 'Failed');
            return;
        }
        if (!code) { setButtonState(btn, 'error', 'Nothing to copy'); return; }

        try {
            await navigator.clipboard.writeText(code);
            setButtonState(btn, 'success', 'Copied!');
        } catch {
            try {
                const ta = document.createElement('textarea');
                ta.value = code;
                Object.assign(ta.style, { position: 'fixed', opacity: '0', pointerEvents: 'none' });
                document.body.appendChild(ta);
                ta.select();
                const ok = document.execCommand('copy');
                ta.remove();
                if (ok) setButtonState(btn, 'success', 'Copied!');
                else    setButtonState(btn, 'error',   'Failed');
            } catch { setButtonState(btn, 'error', 'Failed'); }
        }
    }

    // ─── Build the split button (reusable) ───────────────────────────────────────

    function buildSplitButton() {
        const wrap = document.createElement('div');
        wrap.className = 'gf-split-wrap';

        // Main copy button
        const copyBtn = document.createElement('button');
        copyBtn.className = 'gf-copy-btn';
        copyBtn.innerHTML = `${ICON_COPY}<span>Copier</span>`;
        copyBtn.title = `Copy full script source (${modKey()}+Shift+C)`;
        copyBtn.addEventListener('click', () => copyCode(copyBtn));

        // Divider
        const divider = document.createElement('div');
        divider.className = 'gf-btn-divider';

        // Chevron
        const chevronBtn = document.createElement('button');
        chevronBtn.className = 'gf-chevron-btn';
        chevronBtn.innerHTML = ICON_CHEVRON;
        chevronBtn.title = 'Download options';
        chevronBtn.setAttribute('aria-haspopup', 'true');
        chevronBtn.setAttribute('aria-expanded', 'false');

        // Dropdown
        const dropdown = document.createElement('div');
        dropdown.className = 'gf-dropdown';
        dropdown.innerHTML = `
            <div class="gf-drop-header">Download as</div>
            <div class="gf-drop-divider"></div>
            <div class="gf-drop-item" data-ext="js">${ICON_DOWNLOAD}<span style="flex:1">JavaScript file</span><span class="gf-drop-badge">.js</span></div>
            <div class="gf-drop-item" data-ext="txt">${ICON_DOWNLOAD}<span style="flex:1">Plain text</span><span class="gf-drop-badge">.txt</span></div>
            <div class="gf-drop-item" data-ext="md">${ICON_DOWNLOAD}<span style="flex:1">Markdown (code block)</span><span class="gf-drop-badge">.md</span></div>
        `;

        const allItems = [...dropdown.querySelectorAll('.gf-drop-item')];

        allItems.forEach(item => {
            item.addEventListener('click', () => {
                closeDropdown();
                downloadAs(item.dataset.ext, allItems);
            });
        });

        function openDropdown()  { dropdown.classList.add('open');    chevronBtn.classList.add('open');    chevronBtn.setAttribute('aria-expanded', 'true');  }
        function closeDropdown() { dropdown.classList.remove('open'); chevronBtn.classList.remove('open'); chevronBtn.setAttribute('aria-expanded', 'false'); }
        function isOpen()        { return dropdown.classList.contains('open'); }

        chevronBtn.addEventListener('click', (e) => { e.stopPropagation(); isOpen() ? closeDropdown() : openDropdown(); });
        document.addEventListener('click',   (e) => { if (!wrap.contains(e.target)) closeDropdown(); });
        document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && isOpen()) closeDropdown(); });

        wrap.appendChild(copyBtn);
        wrap.appendChild(divider);
        wrap.appendChild(chevronBtn);
        wrap.appendChild(dropdown);

        return { wrap, copyBtn };
    }

    // ─── CODE PAGE injection ──────────────────────────────────────────────────────

    function injectCodePage() {
        if (document.getElementById('gf-copy-toolbar')) return;

        const codeContainer = document.querySelector('.code-container');
        if (!codeContainer) return;

        const wrapDiv = codeContainer.previousElementSibling;
        const hasWrapDiv = wrapDiv && wrapDiv.querySelector('#wrap-lines');

        const toolbar = document.createElement('div');
        toolbar.id = 'gf-copy-toolbar';

        if (hasWrapDiv) toolbar.appendChild(wrapDiv);

        const { wrap } = buildSplitButton();
        toolbar.appendChild(wrap);

        // Code stats (DOM is available immediately on code page)
        const code = getCodeFromDOM();
        if (code) {
            const stats = document.createElement('span');
            stats.id = 'gf-code-stats';
            stats.title = 'Lines / Characters in this script';
            const lines = code.split('\n').length;
            const chars = code.length;
            stats.innerHTML = `
                <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
                    fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/>
                    <line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/>
                    <line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
                </svg>
                ${fmt(lines)} lines &nbsp;·&nbsp; ${fmt(chars)} chars
            `;
            toolbar.appendChild(stats);
        }

        // Shortcut hint
        const hint = document.createElement('span');
        hint.id = 'gf-shortcut-hint';
        hint.textContent = `${modKey()}+Shift+C`;
        hint.title = 'Keyboard shortcut to copy the code';
        toolbar.appendChild(hint);

        codeContainer.parentNode.insertBefore(toolbar, codeContainer);
    }

    // ─── INFO PAGE injection ──────────────────────────────────────────────────────

    function injectInfoPage() {
        if (document.getElementById('gf-info-copier-wrap')) return;

        // We target the install-area: <a class="install-link"> <a class="install-help-link">
        const installArea = document.getElementById('install-area');
        if (!installArea) return;

        const helpLink = installArea.querySelector('.install-help-link');
        if (!helpLink) return;

        const outerWrap = document.createElement('span');
        outerWrap.id = 'gf-info-copier-wrap';

        const { wrap } = buildSplitButton();
        outerWrap.appendChild(wrap);

        // Insert immediately after the "?" help link
        helpLink.insertAdjacentElement('afterend', outerWrap);
    }

    // ─── Global keyboard shortcut ─────────────────────────────────────────────────

    document.addEventListener('keydown', (e) => {
        const mod = isMac() ? e.metaKey : e.ctrlKey;
        if (mod && e.shiftKey && e.key.toLowerCase() === 'c') {
            e.preventDefault();
            // Find whichever copy button is present
            const btn = document.querySelector('.gf-copy-btn');
            if (btn) copyCode(btn);
        }
    });

    // ─── Init ─────────────────────────────────────────────────────────────────────

    function tryInit(attempts = 0) {
        if (IS_CODE_PAGE) {
            if (document.querySelector('.code-container pre')) {
                injectCodePage();
            } else if (attempts < 20) {
                setTimeout(() => tryInit(attempts + 1), 300);
            }
        } else if (IS_INFO_PAGE) {
            if (document.getElementById('install-area')) {
                injectInfoPage();
            } else if (attempts < 20) {
                setTimeout(() => tryInit(attempts + 1), 300);
            }
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', () => tryInit());
    } else {
        tryInit();
    }
})();