Adds a split Copy/Download button to every Greasy Fork script code page. One click copies, the arrow lets you download as .js, .md, or .txt. Shows line count and supports Ctrl+Shift+C shortcut.
Verzia zo dňa
// ==UserScript==
// @name Greasy Fork - Copy Code Button
// @namespace https://greasyfork.org/
// @version 2.0
// @description Adds a split Copy/Download button to every Greasy Fork script code page. One click copies, the arrow lets you download as .js, .md, or .txt. Shows line count and supports Ctrl+Shift+C shortcut.
// @author achma with claude-AI
// @license MIT
// @match https://greasyfork.org/*/scripts/*/code
// @match https://greasyfork.org/scripts/*/code
// @icon https://greasyfork.org/vite/assets/blacklogo96-CxYTSM_T.png
// @grant none
// @run-at document-idle
// ==/UserScript==
(() => {
'use strict';
// ─── Styles ───────────────────────────────────────────────────────────────────
const style = document.createElement('style');
style.textContent = `
#gf-copy-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
/* ── Split button wrapper ── */
#gf-split-btn-wrap {
display: inline-flex;
align-items: stretch;
border-radius: 7px;
position: relative;
box-shadow: 0 2px 8px rgba(0,0,0,0.22);
}
/* ── 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;
}
/* ── Divider between copy and chevron ── */
#gf-btn-divider {
width: 1px;
background: #3a3f4a;
align-self: stretch;
flex-shrink: 0;
}
/* ── Chevron / dropdown trigger ── */
#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 menu ── */
#gf-dropdown {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 210px;
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-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 stats ── */
#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;
}
/* ── Shortcut hint ── */
#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;
}
@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);
// ─── 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_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>`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getCodeText() {
const pre = document.querySelector('.code-container pre');
if (!pre) return null;
return pre.innerText || pre.textContent || null;
}
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';
}
function formatNumber(n) { return n.toLocaleString(); }
function isMac() { return /Mac|iPhone|iPod|iPad/.test(navigator.platform); }
function modKey() { return isMac() ? '⌘' : 'Ctrl'; }
// ─── Download ─────────────────────────────────────────────────────────────────
function downloadAs(ext) {
const code = getCodeText();
if (!code) return;
const name = getScriptName();
let content = code;
let mime = 'text/plain';
if (ext === 'js') { mime = 'text/javascript'; }
if (ext === 'md') {
const title = name.replace(/_/g, ' ');
content = `# ${title}\n\n\`\`\`javascript\n${code}\n\`\`\`\n`;
mime = 'text/markdown';
}
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: `${name}.${ext}` });
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ─── Copy ─────────────────────────────────────────────────────────────────────
let resetTimer = null;
async function copyCode(btn) {
const code = getCodeText();
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');
}
}
}
function setButtonState(btn, state, label) {
clearTimeout(resetTimer);
btn.classList.remove('gf-copied', 'gf-error');
if (state === 'success') {
btn.classList.add('gf-copied');
btn.innerHTML = `${ICON_CHECK}<span>${label}</span>`;
} else if (state === 'error') {
btn.classList.add('gf-error');
btn.innerHTML = `${ICON_ERROR}<span>${label}</span>`;
}
resetTimer = setTimeout(() => {
btn.classList.remove('gf-copied', 'gf-error');
btn.innerHTML = `${ICON_COPY}<span>Copier</span>`;
}, 2500);
}
// ─── Build UI ─────────────────────────────────────────────────────────────────
function injectToolbar() {
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);
// ── Split button wrapper
const splitWrap = document.createElement('div');
splitWrap.id = 'gf-split-btn-wrap';
// ── Copy button
const copyBtn = document.createElement('button');
copyBtn.id = 'gf-copy-btn';
copyBtn.innerHTML = `${ICON_COPY}<span>Copier</span>`;
copyBtn.title = `Copy full script source (${modKey()}+Shift+C)`;
copyBtn.addEventListener('click', () => copyCode(copyBtn));
// ── Visual divider
const divider = document.createElement('div');
divider.id = 'gf-btn-divider';
// ── Chevron button
const chevronBtn = document.createElement('button');
chevronBtn.id = '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.id = 'gf-dropdown';
dropdown.setAttribute('role', 'menu');
dropdown.innerHTML = `
<div class="gf-drop-header">Download as</div>
<div class="gf-drop-divider"></div>
<div class="gf-drop-item" data-ext="js" role="menuitem">
${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" role="menuitem">
${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" role="menuitem">
${ICON_DOWNLOAD}
<span style="flex:1">Markdown (code block)</span>
<span class="gf-drop-badge">.md</span>
</div>
`;
dropdown.querySelectorAll('.gf-drop-item').forEach(item => {
item.addEventListener('click', () => {
downloadAs(item.dataset.ext);
closeDropdown();
});
});
// ── Dropdown toggle logic
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 (!splitWrap.contains(e.target)) closeDropdown();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && isOpen()) closeDropdown();
});
splitWrap.appendChild(copyBtn);
splitWrap.appendChild(divider);
splitWrap.appendChild(chevronBtn);
splitWrap.appendChild(dropdown);
toolbar.appendChild(splitWrap);
// ── Code stats
const code = getCodeText();
if (code) {
const lines = code.split('\n').length;
const chars = code.length;
const stats = document.createElement('span');
stats.id = 'gf-code-stats';
stats.title = 'Lines / Characters in this script';
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>
${formatNumber(lines)} lines · ${formatNumber(chars)} chars
`;
toolbar.appendChild(stats);
}
// ── Keyboard 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);
}
// ─── Keyboard shortcut ────────────────────────────────────────────────────────
document.addEventListener('keydown', (e) => {
const mod = isMac() ? e.metaKey : e.ctrlKey;
if (mod && e.shiftKey && e.key.toLowerCase() === 'c') {
e.preventDefault();
const btn = document.getElementById('gf-copy-btn');
if (btn) copyCode(btn);
}
});
// ─── Init ─────────────────────────────────────────────────────────────────────
function tryInit(attempts = 0) {
if (document.querySelector('.code-container pre')) {
injectToolbar();
} else if (attempts < 20) {
setTimeout(() => tryInit(attempts + 1), 300);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => tryInit());
} else {
tryInit();
}
})();