ExeTools Forum Paste Upload

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.

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

Advertisement:

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

Advertisement:

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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();

})();