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.
// ==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 · ${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();
}
})();