// ==UserScript==
// @name 通用拖拽上传替换 (相邻/覆盖/替换 可选 + 悬浮提示 + 日志)
// @namespace https://muyyy.link/
// @version 0.8
// @description 为 input[type=file] 提供拖拽上传弹窗;支持相邻/覆盖/直接替换原有按钮;替换模式不改文字与布局,仅悬浮提示;Alt 可直通
// @author Muyu
// @homepage https://muyyy.link/
// @license Apache-2.0
// @match *://*/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
insertMode: 'replace', // 'adjacent' | 'cover' | 'replace'
accentColor: '#4CAF50',
log: true,
altBypass: true, // 按住 Alt 时让出原生行为(不拦截)
tooltipText: '拖拽到弹窗,或点击选择(按住 Alt 走原生)',
};
const style = document.createElement('style');
style.textContent = `
/* 轻量强调,不覆盖站点按钮样式 */
.uploader-btn { border: 2px solid ${CONFIG.accentColor} !important; }
.uploader-btn:hover { background-color: #e8f5e9 !important; border-color: #2e7d32 !important; }
/* 隐藏 input,但不 display:none,避免脚本找不到它 */
.uploader-hidden {
position: absolute !important; width: 1px !important; height: 1px !important;
padding: 0 !important; margin: 0 !important; overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important;
pointer-events: none !important; opacity: 0 !important;
}
.uploader-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.3);
display: flex; align-items: center; justify-content: center; z-index: 999999;
}
.uploader-box {
background: #fff; border: 2px solid ${CONFIG.accentColor}; border-radius: 6px;
padding: 20px 30px; text-align: center; box-shadow: 0 4px 10px rgba(0,0,0,0.2);
max-width: 90vw;
}
.uploader-box p { margin-bottom: 12px; font-weight: bold; color: #333; }
.uploader-box button { padding: 6px 12px; margin: 5px; border: 1px solid #ccc; border-radius: 4px; background: #f0f0f0; cursor: pointer; }
.uploader-box button:hover { background: #e0e0e0; }
/* 悬浮提示(仅在替换模式给原按钮加 data-uploader-tip 时出现) */
[data-uploader-tip] { position: relative; }
[data-uploader-tip]:hover::after {
content: attr(data-uploader-tip);
position: absolute; top: -32px; left: 50%; transform: translateX(-50%);
background: rgba(0,0,0,0.85); color: #fff; padding: 4px 8px; border-radius: 4px;
font-size: 12px; white-space: nowrap; pointer-events: none; z-index: 1000000;
box-shadow: 0 2px 6px rgba(0,0,0,0.2);
}
[data-uploader-tip]:hover::before {
content: ''; position: absolute; top: -8px; left: 50%; transform: translateX(-50%);
border: 6px solid transparent; border-top-color: rgba(0,0,0,0.85); z-index: 1000001;
}
`;
document.head.appendChild(style);
const log = (...a) => CONFIG.log && console.log('[Uploader]', ...a);
const isVisible = (el) => {
if (!el || !el.getBoundingClientRect) return false;
const cs = getComputedStyle(el);
const r = el.getBoundingClientRect();
return cs.display !== 'none' && cs.visibility !== 'hidden' && r.width > 0 && r.height > 0;
};
const cssEscape = (id) => (
window.CSS && CSS.escape
? CSS.escape(id)
: id.replace(/([ #;?%&,.+*~\\':"!^$[\]()=>|/@])/g, '\\$1')
);
function findAnchor(input) {
try {
if (input.id) {
const byFor = document.querySelector(`label[for="${cssEscape(input.id)}"]`);
if (byFor && isVisible(byFor)) return { anchor: byFor, type: 'label[for]' };
}
const labelWrap = input.closest && input.closest('label');
if (labelWrap && isVisible(labelWrap)) return { anchor: labelWrap, type: 'label-ancestor' };
const btnLike = input.closest && input.closest('button, .btn, [role="button"], .button, .ant-btn, .MuiButton-root, .el-button');
if (btnLike && isVisible(btnLike)) return { anchor: btnLike, type: 'button-like' };
if (isVisible(input)) return { anchor: input, type: 'input-self-visible' };
return { anchor: input.parentElement || input, type: 'fallback-parent' };
} catch {
return { anchor: input, type: 'error-fallback' };
}
}
function makeOverlay(input) {
const overlay = document.createElement('div');
overlay.className = 'uploader-overlay';
overlay.innerHTML = `
<div class="uploader-box" role="dialog" aria-label="上传文件">
<p>拖拽文件到此,或点击按钮选择</p>
<button id="manualSelect" type="button">手动选择</button>
<button id="closeUpload" type="button">取消</button>
</div>`;
document.body.appendChild(overlay);
const box = overlay.querySelector('.uploader-box');
overlay.addEventListener('dragover', (e) => { e.preventDefault(); box.style.borderColor = '#2e7d32'; });
overlay.addEventListener('dragleave', () => { box.style.borderColor = CONFIG.accentColor; });
overlay.addEventListener('drop', (e) => {
e.preventDefault();
box.style.borderColor = CONFIG.accentColor;
const files = e.dataTransfer.files;
log('检测到拖入文件:', files);
if (files.length > 0) {
const dt = new DataTransfer();
for (const f of files) dt.items.add(f);
input.files = dt.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
log('文件已注入 input 并触发 change 事件');
}
overlay.remove();
});
overlay.querySelector('#manualSelect').addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
log('手动选择文件:触发 input.click()');
input.click();
overlay.remove();
});
overlay.querySelector('#closeUpload').addEventListener('click', () => {
log('用户取消上传');
overlay.remove();
});
}
function hookAnchorToOverlay(anchor, input) {
if (anchor.dataset.uploaderHooked === '1') return;
anchor.dataset.uploaderHooked = '1';
// 悬浮提示:不改文本与样式,仅加 data- 属性
if (!anchor.hasAttribute('data-uploader-tip')) {
anchor.setAttribute('data-uploader-tip', CONFIG.tooltipText);
}
const clickHandler = (e) => {
if (CONFIG.altBypass && (e.altKey || e.getModifierState?.('Alt'))) {
log('Alt 按下:放行原生行为与站点事件'); return; // 不拦截
}
// 拦截并优先于站点脚本(捕获阶段+阻止默认+阻止冒泡)
e.preventDefault();
e.stopPropagation();
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
log('原按钮被替换触发:打开自定义 overlay', { anchor, input });
makeOverlay(input);
};
// 捕获阶段监听,优先拿到 click
anchor.addEventListener('click', clickHandler, true);
// 无障碍支持:回车/空格
anchor.addEventListener('keydown', (e) => {
const key = e.key || e.code;
if (key === 'Enter' || key === ' ' || key === 'Spacebar') {
if (CONFIG.altBypass && (e.altKey || e.getModifierState?.('Alt'))) return;
e.preventDefault(); e.stopPropagation();
log('键盘触发(Enter/Space)打开 overlay');
makeOverlay(input);
}
}, true);
}
function enhanceInput(input) {
if (!(input instanceof Element)) return;
if (input.dataset.uploaderEnhanced === '1') return;
input.dataset.uploaderEnhanced = '1';
const { anchor, type } = findAnchor(input);
log('定位锚点', { input, anchor, type, mode: CONFIG.insertMode });
if (CONFIG.insertMode === 'replace') {
// 直接替换:用原“按钮/label/可见input”本体作为触发器;不改文字与样式
hookAnchorToOverlay(anchor, input);
// 如果锚点不是 input 本体,则可以安全隐藏 input 本体,避免抢焦点/挡点击;若锚点就是 input,则保持可见以不变布局
if (anchor !== input) {
input.classList.add('uploader-hidden');
}
return;
}
// 下方保留相邻/覆盖两种旧策略(如需切换)
// 复制原 class 到按钮
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '上传文件';
btn.className = (anchor.className || input.className || '') + ' uploader-btn';
btn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
log('自定义上传按钮被点击');
makeOverlay(input);
});
if (CONFIG.insertMode === 'cover' && isVisible(anchor)) {
const wrap = document.createElement('span');
const cs = getComputedStyle(anchor);
wrap.style.position = cs.position === 'static' ? 'relative' : cs.position;
wrap.style.display = 'inline-block';
anchor.parentNode.insertBefore(wrap, anchor);
wrap.appendChild(anchor);
const bStyle = btn.style;
bStyle.position = 'absolute';
bStyle.inset = '0';
bStyle.width = '100%';
bStyle.height = '100%';
bStyle.display = 'inline-flex';
bStyle.alignItems = 'center';
bStyle.justifyContent = 'center';
bStyle.background = 'transparent';
wrap.appendChild(btn);
input.classList.add('uploader-hidden');
log('已覆盖锚点放置按钮');
} else {
anchor.insertAdjacentElement('afterend', btn);
input.classList.add('uploader-hidden');
log('已相邻插入按钮');
}
}
function processAllInputs(root = document) {
root.querySelectorAll('input[type="file"]').forEach(enhanceInput);
}
const mo = new MutationObserver((muts) => {
for (const m of muts) {
for (const n of m.addedNodes) {
if (n.nodeType !== 1) continue;
if (n.matches && n.matches('input[type="file"]')) enhanceInput(n);
const inputs = n.querySelectorAll ? n.querySelectorAll('input[type="file"]') : [];
inputs.forEach(enhanceInput);
}
}
});
// 启动
processAllInputs();
if (document.body) {
mo.observe(document.body, { childList: true, subtree: true });
} else {
document.addEventListener('DOMContentLoaded', () => {
processAllInputs();
mo.observe(document.body, { childList: true, subtree: true });
});
}
})();