On new thread, reply, and advanced edit pages, press Ctrl+V to upload clipboard images as attachments. Images exceeding forum limits (e.g. PNG/JPG/GIF/BMP over 1.91 MB) are auto-converted to JPEG and compressed.
// ==UserScript==
// @name ExeTools Forum Paste Upload
// @namespace https://forum.exetools.com/
// @version 7.0.0
// @description On new thread, reply, and advanced edit pages, press Ctrl+V to upload clipboard images as attachments. Images exceeding forum limits (e.g. PNG/JPG/GIF/BMP over 1.91 MB) are auto-converted to JPEG and compressed.
// Supports: newthread, newreply, editpost.
// Note: quick edit on showthread does not support attachments; use "Go Advanced" first.
// @author WhoCares@exetools
// @match *://forum.exetools.com/newthread.php*
// @match *://forum.exetools.com/newreply.php*
// @match *://forum.exetools.com/editpost.php*
// @grant none
// @license MIT
// @run-at document-end
// ==/UserScript==
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════════
// Parameter collection
//
// Read hidden fields from form[name="vbform"] required for uploads:
// securitytoken / posthash / poststarttime / f / t / p / editpost
//
// posthash is a temporary draft token (random per edit session).
// The server binds uploaded attachments to the current session via
// posthash; after posting/saving they attach to the post and the
// posthash is discarded.
// ═══════════════════════════════════════════════════════════════
function collectParams() {
const form = document.querySelector('form[name="vbform"]')
|| document.querySelector('form#vbform');
if (!form) return null;
const fv = (name) => {
const el = form.elements[name];
if (!el) return '';
return typeof el.value === 'string' ? el.value : (el[0]?.value ?? '');
};
const securitytoken = fv('securitytoken')
|| (typeof SECURITYTOKEN !== 'undefined' ? SECURITYTOKEN : '');
const f = fv('f') || new URLSearchParams(location.search).get('f') || '';
const t = fv('t') || new URLSearchParams(location.search).get('t') || '0';
const p = fv('p') || '0';
return {
s: fv('s'),
securitytoken,
do: 'manageattach',
t, f, p,
poststarttime: fv('poststarttime'),
editpost: fv('editpost') || '0',
posthash: fv('posthash'),
};
}
function buildUploadUrl() {
const dir = location.pathname.replace(/\/[^/]+$/, '');
return `${location.origin}${dir}/newattachment.php?do=manageattach&p=`;
}
// ═══════════════════════════════════════════════════════════════
// Random filename: timestamp + 8 hex chars from crypto, avoids collisions
// ═══════════════════════════════════════════════════════════════
function randomFilename(mimeType) {
const ext = mimeType.split('/')[1].replace('jpeg', 'jpg');
const buf = new Uint8Array(4);
crypto.getRandomValues(buf);
const hex = Array.from(buf, b => b.toString(16).padStart(2, '0')).join('');
return `paste_${Date.now()}_${hex}.${ext}`;
}
// ═══════════════════════════════════════════════════════════════
// Debounce + upload mutex
// Only one run within DEBOUNCE_MS; while uploading, show wait message
// ═══════════════════════════════════════════════════════════════
const DEBOUNCE_MS = 1500;
let _uploading = false;
let _debounceTimer = null;
function withGuard(fn) {
if (_uploading) { toast('⏳ Previous image still uploading, please wait…', 'info', 2000); return; }
if (_debounceTimer){ toast('⚡ Too many attempts, please wait a moment', 'info', 1500); return; }
_debounceTimer = setTimeout(() => { _debounceTimer = null; }, DEBOUNCE_MS);
fn();
}
// ═══════════════════════════════════════════════════════════════
// UI: toast notifications + upload overlay
// ═══════════════════════════════════════════════════════════════
function injectStyles() {
if (document.getElementById('__expaste_sty__')) return;
const s = document.createElement('style');
s.id = '__expaste_sty__';
s.textContent = `
#__expaste_toast__ {
position:fixed; bottom:28px; left:50%;
transform:translateX(-50%) translateY(14px);
background:#12192b; color:#e2e8f0;
padding:10px 22px; border-radius:10px;
font:14px/1.6 "Segoe UI",sans-serif;
box-shadow:0 6px 28px rgba(0,0,0,.55);
z-index:2147483647; opacity:0; pointer-events:none;
transition:opacity .22s,transform .22s;
white-space:nowrap; max-width:88vw;
overflow:hidden; text-overflow:ellipsis;
}
#__expaste_toast__.show { opacity:1; transform:translateX(-50%) translateY(0); }
#__expaste_toast__.ok { border-left:4px solid #68d391; }
#__expaste_toast__.err { border-left:4px solid #fc8181; }
#__expaste_toast__.info { border-left:4px solid #63b3ed; }
#__expaste_mask__ {
display:none; position:fixed; inset:0;
background:rgba(0,0,0,.5); z-index:2147483646;
align-items:center; justify-content:center;
flex-direction:column; gap:14px;
color:#fff; font:15px/1.6 "Segoe UI",sans-serif;
}
#__expaste_mask__.on { display:flex; }
#__expaste_mask__ .sp {
width:44px; height:44px;
border:4px solid rgba(255,255,255,.25);
border-top-color:#fff; border-radius:50%;
animation:expaste_spin .75s linear infinite;
}
@keyframes expaste_spin { to { transform:rotate(360deg); } }
#__expaste_hint__ {
display:block; margin:5px 0 4px; padding:5px 11px;
background:#0d2137; color:#7ec8e3;
border-left:3px solid #3b9fd8; border-radius:5px;
font-size:12px; line-height:1.5;
}
#__expaste_hint__ kbd {
background:#1e3a52; color:#e2e8f0;
padding:1px 5px; border-radius:3px;
font-size:11px; font-family:monospace;
border:1px solid #4a7fa5;
}
#__expaste_log__ { margin:3px 0; font-size:12px; }
#__expaste_log__ div { padding:2px 0; color:#68d391; }
`;
document.head.appendChild(s);
}
let _toastTimer;
function toast(msg, type = 'info', ms = 3500) {
let el = document.getElementById('__expaste_toast__');
if (!el) { el = document.createElement('div'); el.id = '__expaste_toast__'; document.body.appendChild(el); }
el.textContent = msg;
el.className = type;
void el.offsetWidth;
el.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => el.classList.remove('show'), ms);
}
function mask(show, msg = 'Uploading image, please wait…') {
let el = document.getElementById('__expaste_mask__');
if (!el) {
el = document.createElement('div'); el.id = '__expaste_mask__';
el.innerHTML = '<div class="sp"></div><span></span>';
document.body.appendChild(el);
}
el.querySelector('span').textContent = msg;
el.classList.toggle('on', show);
}
// ═══════════════════════════════════════════════════════════════
// Image conversion (to JPEG)
// ═══════════════════════════════════════════════════════════════
function convertToJpeg(blob) {
return new Promise((resolve) => {
const img = new Image();
const url = URL.createObjectURL(blob);
img.onload = () => {
URL.revokeObjectURL(url);
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
// White background (prevents transparent PNG areas turning black)
ctx.fillStyle = '#FFFFFF';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
canvas.toBlob((newBlob) => {
resolve(newBlob || blob); // fall back to original on failure
}, 'image/jpeg', 0.90);
};
img.onerror = () => {
URL.revokeObjectURL(url);
resolve(blob);
};
img.src = url;
});
}
// ExeTools forum per-type image limit (from attachment key table)
const LIMIT_IMAGE = 1.91 * 1024 * 1024;
// ═══════════════════════════════════════════════════════════════
// Core: upload image to newattachment.php
// ═══════════════════════════════════════════════════════════════
async function uploadImage(file) {
const params = collectParams();
if (!params) {
toast('❌ Post form not found (form[name=vbform])', 'err', 5000);
return;
}
if (!params.securitytoken) {
toast('❌ Missing securitytoken — refresh the page and try again', 'err', 5000);
return;
}
if (!params.posthash) {
toast('❌ Missing posthash — refresh the page and try again', 'err', 5000);
return;
}
_uploading = true;
try {
let uploadBlob = file;
// Decide whether to convert/compress based on forum size limits
let shouldConvert = false;
if (file.type === 'image/jpeg' || file.type === 'image/jpg') {
shouldConvert = file.size > LIMIT_IMAGE;
} else if (file.type.startsWith('image/')) {
// PNG, GIF, BMP, WebP, etc. — convert if over limit or uncommon format
shouldConvert = file.size > LIMIT_IMAGE
|| (file.type !== 'image/png'
&& file.type !== 'image/gif'
&& file.type !== 'image/bmp');
}
if (shouldConvert) {
mask(true, 'Converting to JPEG or compressing to meet forum size limits…');
uploadBlob = await convertToJpeg(file);
}
const finalExt = uploadBlob.type === 'image/jpeg' ? 'image/jpeg' : uploadBlob.type;
const finalFile = new File([uploadBlob], randomFilename(finalExt), { type: finalExt });
const fd = new FormData();
for (const [k, v] of Object.entries(params)) fd.append(k, v);
fd.append('MAX_FILE_SIZE', '52428800');
fd.append('attachment[]', finalFile, finalFile.name);
fd.append('upload', 'Upload');
mask(true, `Uploading ${finalFile.name} (${(finalFile.size / 1024).toFixed(1)} KB)…`);
const resp = await fetch(buildUploadUrl(), {
method: 'POST', body: fd, credentials: 'include',
});
if (!resp.ok) { toast(`❌ HTTP ${resp.status} — upload failed`, 'err', 6000); return; }
const html = await resp.text();
const errMatch = html.match(/(?:error|failed|invalid|too large|exceed|cannot|unable)[^<]{0,80}/i);
if (errMatch) { toast(`❌ ${errMatch[0].trim()}`, 'err', 7000); return; }
toast(`✅ "${finalFile.name}" uploaded! It will attach when you submit or save.`, 'ok', 5000);
logUpload(finalFile.name, finalFile.size);
} catch (err) {
toast(`❌ Network error: ${err.message}`, 'err', 6000);
console.error('[ExeTools paste upload]', err);
} finally {
mask(false);
_uploading = false;
}
}
function logUpload(name, size) {
let log = document.getElementById('__expaste_log__');
if (!log) {
log = document.createElement('div'); log.id = '__expaste_log__';
const hint = document.getElementById('__expaste_hint__');
hint ? hint.after(log) : document.body.appendChild(log);
}
const row = document.createElement('div');
row.textContent = `✔ ${name} (${(size / 1024).toFixed(1)} KB) uploaded`;
log.appendChild(row);
}
// ═══════════════════════════════════════════════════════════════
// Event listeners
// ═══════════════════════════════════════════════════════════════
function imageFromClipboard(dt) {
if (!dt?.items) return null;
for (const item of dt.items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
const blob = item.getAsFile();
if (blob) return new File([blob], randomFilename(item.type), { type: item.type });
}
}
return null;
}
// paste event: covers most paste scenarios
function onPaste(e) {
const file = imageFromClipboard(e.clipboardData);
if (!file) return; // no image — allow default behavior
e.preventDefault();
withGuard(() => uploadImage(file));
}
// keydown fallback: read clipboard via Clipboard API (HTTPS + permission required)
// only when focus is not in a text field (text fields use paste event)
function onKeyDown(e) {
if (!((e.ctrlKey || e.metaKey) && e.key === 'v')) return;
const active = document.activeElement;
if (active && (active.tagName === 'TEXTAREA' ||
(active.tagName === 'INPUT' && active.type !== 'file'))) return;
if (!navigator.clipboard?.read) return;
navigator.clipboard.read().then(items => {
for (const item of items) {
const imgType = item.types.find(t => t.startsWith('image/'));
if (imgType) {
item.getType(imgType).then(blob => {
const file = new File([blob], randomFilename(imgType), { type: imgType });
withGuard(() => uploadImage(file));
});
return;
}
}
}).catch(() => {});
}
// ═══════════════════════════════════════════════════════════════
// Init: insert hint bar, register events
// ═══════════════════════════════════════════════════════════════
function insertHint() {
if (document.getElementById('__expaste_hint__')) return;
const hint = document.createElement('div');
hint.id = '__expaste_hint__';
hint.innerHTML = '💡 After taking a screenshot, press <kbd>Ctrl+V</kbd> on a blank area of the page to upload the image as an attachment — no need to open "Manage Attachments"';
// Insert before the Manage Attachments button (ID confirmed in forum HTML)
const btn = document.getElementById('manage_attachments_button');
const al = document.getElementById('attachlist');
const anchor = btn || al;
if (anchor) anchor.parentNode.insertBefore(hint, anchor);
}
function init() {
injectStyles();
insertHint();
document.addEventListener('paste', onPaste, true);
document.addEventListener('keydown', onKeyDown, true);
console.log('[ExeTools paste upload v7.0] active ✓');
}
init();
})();