// ==UserScript==
// @name 通用拖拽上传(Drag & Drop Uploader)
// @namespace https://muyyy.link/
// @version 1.1.0
// @description 为网页 input[type=file] 增加拖拽上传弹窗:点击触发 + 拖拽注入;支持 adjacent/cover/replace 三种挂载模式;replace 模式不改原按钮文案与布局,仅添加悬浮提示;支持 Alt 直通原生站点行为;兼容动态页面(MutationObserver)。
// @author (your name)
// @match *://*/*
// @include *://*/*
// @exclude *://*.bank*/*
// @exclude *://*.paypal.com/*
// @exclude *://*.chase.com/*
// @exclude *://*.wellsfargo.com/*
// @exclude *://*.citi.com/*
// @exclude *://*.capitalone.com/*
// @exclude *://*.apple.com/*
// @exclude *://*.microsoft.com/*
// @exclude *://login.*/*
// @exclude *://accounts.*/*
// @grant none
// @downloadURL https://update.greasyfork.icu/scripts/549567/%E9%80%9A%E7%94%A8%E6%8B%96%E6%8B%BD%E4%B8%8A%E4%BC%A0%EF%BC%88Drag%20%20Drop%20Uploader%EF%BC%89.user.js
// @updateURL https://update.greasyfork.icu/scripts/549567/%E9%80%9A%E7%94%A8%E6%8B%96%E6%8B%BD%E4%B8%8A%E4%BC%A0%EF%BC%88Drag%20%20Drop%20Uploader%EF%BC%89.meta.js
// ==/UserScript==
(function () {
'use strict';
const CONFIG = {
insertMode: 'replace', // 'adjacent' | 'cover' | 'replace'
accentColor: '#4CAF50',
log: true,
altBypass: true, // 按住 Alt 时让出原生行为(不拦截)
tooltipText: '拖拽到弹窗,或点击选择(按住 Alt 走原生)',
};
// 创建全局 tooltip 容器
const tooltipContainer = document.createElement('div');
tooltipContainer.id = 'uploader-tooltip-container';
tooltipContainer.style.cssText = 'position: fixed; top: 0; left: 0; z-index: 2147483647; pointer-events: none;';
document.body.appendChild(tooltipContainer);
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: 2147483647;
}
.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; }
/* Tooltip 样式 */
.uploader-tooltip {
position: fixed; background: rgba(0,0,0,0.85); color: #fff; padding: 4px 8px;
border-radius: 4px; font-size: 12px; white-space: nowrap; z-index: 2147483645;
box-shadow: 0 2px 6px rgba(0,0,0,0.2); pointer-events: none;
display: none;
}
.uploader-tooltip.visible { display: block; }
.uploader-tooltip-arrow {
position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
border: 6px solid transparent; border-top-color: rgba(0,0,0,0.85);
}
`;
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(findInputFn) {
const overlay = document.createElement('div');
overlay.className = 'uploader-overlay';
overlay.innerHTML = `
`;
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 input = findInputFn();
if (!input || !input.isConnected) {
log('警告:未找到关联的 input 元素');
overlay.remove();
return;
}
// 记录原状态
const hadHiddenClass = input.classList.contains('uploader-hidden');
const oldAriaHidden = input.getAttribute('aria-hidden');
const oldInert = input.hasAttribute('inert');
const oldDisplay = input.style.display;
const oldVisibility = input.style.visibility;
const oldPosition = input.style.position;
const oldLeft = input.style.left;
const oldTop = input.style.top;
const oldWidth = input.style.width;
const oldHeight = input.style.height;
// 临时解封
input.classList.remove('uploader-hidden');
input.removeAttribute('aria-hidden');
if (oldInert) input.removeAttribute('inert');
input.style.display = 'block';
input.style.visibility = 'visible';
input.style.position = 'fixed';
input.style.left = '-9999px';
input.style.top = '0';
input.style.width = '1px';
input.style.height = '1px';
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 事件');
// 恢复原状态
setTimeout(() => {
if (input && input.parentElement) {
if (oldAriaHidden !== null) {
input.setAttribute('aria-hidden', oldAriaHidden);
} else {
input.removeAttribute('aria-hidden');
}
if (oldInert) {
input.setAttribute('inert', '');
} else {
input.removeAttribute('inert');
}
input.style.display = oldDisplay;
input.style.visibility = oldVisibility;
input.style.position = oldPosition;
input.style.left = oldLeft;
input.style.top = oldTop;
input.style.width = oldWidth;
input.style.height = oldHeight;
if (hadHiddenClass) input.classList.add('uploader-hidden');
}
}, 100);
}
overlay.remove();
});
overlay.querySelector('#manualSelect').addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
log('手动选择:查找可用 input');
const live = findInputFn();
if (!live || !live.isConnected) {
log('找不到可用的 file input');
return;
}
// 记录原父节点和位置
const originalParent = live.parentElement;
const originalNextSibling = live.nextSibling;
// 记录原状态
const hadHiddenClass = live.classList.contains('uploader-hidden');
const oldAriaHidden = live.getAttribute('aria-hidden');
const oldInert = live.hasAttribute('inert');
const oldDisplay = live.style.display;
const oldVisibility = live.style.visibility;
const oldPosition = live.style.position;
const oldLeft = live.style.left;
const oldTop = live.style.top;
const oldOpacity = live.style.opacity;
const oldPointer = live.style.pointerEvents;
const oldWidth = live.style.width;
const oldHeight = live.style.height;
const oldClip = live.style.clip;
const oldTabindex = live.getAttribute('tabindex');
log('临时解封 input', { hadHiddenClass, oldAriaHidden, oldInert });
// ---- 临时解封:让 Chromium 认为它可交互 ----
try { live.value = ''; } catch {}
live.classList.remove('uploader-hidden');
live.removeAttribute('aria-hidden');
if (oldInert) live.removeAttribute('inert');
live.removeAttribute('tabindex');
// 用"看不见但可交互"的方式:挪到 body,position: fixed 推屏幕外
live.style.display = 'block';
live.style.visibility = 'visible';
live.style.position = 'fixed';
live.style.left = '-9999px';
live.style.top = '0';
live.style.width = 'auto';
live.style.height = 'auto';
live.style.opacity = '1';
live.style.pointerEvents = 'auto';
live.style.clip = 'auto';
// 关键:临时把 input 挪到 body,避免被父容器的 clip/overflow/其他样式隐藏
document.body.appendChild(live);
log('已临时解封并挪至 body,准备 click()');
// 同步触发 file picker(必须在本次点击事件内)
live.click();
// 关闭 overlay 异步,避免影响弹窗
setTimeout(() => overlay.remove(), 0);
// 恢复函数
const restore = () => {
window.removeEventListener('focus', restore, true);
if (!live.isConnected) {
log('input 已被移除,无需恢复');
return;
}
// 恢复原属性/样式
if (oldAriaHidden !== null) {
live.setAttribute('aria-hidden', oldAriaHidden);
} else {
live.removeAttribute('aria-hidden');
}
if (oldInert) {
live.setAttribute('inert', '');
} else {
live.removeAttribute('inert');
}
if (oldTabindex !== null) {
live.setAttribute('tabindex', oldTabindex);
} else {
live.removeAttribute('tabindex');
}
live.style.display = oldDisplay;
live.style.visibility = oldVisibility;
live.style.position = oldPosition;
live.style.left = oldLeft;
live.style.top = oldTop;
live.style.width = oldWidth;
live.style.height = oldHeight;
live.style.opacity = oldOpacity;
live.style.pointerEvents = oldPointer;
live.style.clip = oldClip;
// 挪回原位置
if (originalParent && originalParent.isConnected) {
if (originalNextSibling) {
originalParent.insertBefore(live, originalNextSibling);
} else {
originalParent.appendChild(live);
}
} else {
document.body.appendChild(live);
}
if (hadHiddenClass) {
live.classList.add('uploader-hidden');
}
log('已恢复 input 原状态和位置');
};
// 文件选择器关闭后通常会触发 window focus 事件
window.addEventListener('focus', restore, true);
// 兜底:1.5s 后强制恢复(防止某些情况下 focus 没回来)
setTimeout(restore, 1500);
});
overlay.querySelector('#closeUpload').addEventListener('click', () => {
log('用户取消上传');
overlay.remove();
});
}
function hookAnchorToOverlay(anchor, input) {
if (anchor.dataset.uploaderHooked === '1') return;
anchor.dataset.uploaderHooked = '1';
// 创建一个查找函数,每次都动态找到最新的 input
// 这样可以处理页面重建 input 的情况
const findInput = () => {
// 1. 如果 anchor 本身是 input,直接返回
if (anchor.tagName === 'INPUT' && anchor.type === 'file') {
return anchor;
}
// 2. 尝试找到与原 input 相同结构的新 input
// 先在 anchor 及其父元素内查找
let found = anchor.querySelector('input[type="file"]');
if (found) return found;
// 3. 检查是否还能找到原来的 input(如果页面没重建)
if (input && input.parentElement) {
const newInput = input.ownerDocument?.querySelector(
`input[type="file"][${[...input.attributes].map(a => `${a.name}="${a.value}"`).join(', ')}]`
);
if (newInput) return newInput;
}
// 4. 如果原 input 仍在 DOM 中且有效,返回它
if (input && input.parentElement && input.type === 'file') {
return input;
}
// 5. 最后的兜底:查找 anchor 所在容器内的所有 input[type=file]
// 选择最后一个(最可能是新创建的)
const container = anchor.closest('[class*="upload"], [class*="form"], form') || anchor.parentElement;
if (container) {
const inputs = container.querySelectorAll('input[type="file"]');
if (inputs.length > 0) return inputs[inputs.length - 1];
}
// 6. 实在找不到,就在整个 document 里找最后的 input[type=file]
const allInputs = document.querySelectorAll('input[type="file"]');
if (allInputs.length > 0) return allInputs[allInputs.length - 1];
return null;
};
// 创建 tooltip 元素(不在 DOM 里,在需要时插入)
let tooltip = null;
const showTooltip = () => {
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.className = 'uploader-tooltip';
tooltip.innerHTML = `${CONFIG.tooltipText}`;
tooltipContainer.appendChild(tooltip);
}
const rect = anchor.getBoundingClientRect();
tooltip.style.left = (rect.left + rect.width / 2) + 'px';
tooltip.style.top = (rect.top - 38) + 'px';
tooltip.style.transform = 'translateX(-50%)';
tooltip.classList.add('visible');
};
const hideTooltip = () => {
if (tooltip) {
tooltip.classList.remove('visible');
}
};
// Hover 事件显示/隐藏 tooltip
anchor.addEventListener('mouseenter', showTooltip);
anchor.addEventListener('mouseleave', hideTooltip);
const clickHandler = (e) => {
if (CONFIG.altBypass && (e.altKey || e.getModifierState?.('Alt'))) {
log('Alt 按下:临时解封 input,触发原生上传框');
const inputEl = findInput();
if (inputEl && inputEl.isConnected) {
// 记录原状态
const hadHiddenClass = inputEl.classList.contains('uploader-hidden');
const oldAriaHidden = inputEl.getAttribute('aria-hidden');
const oldInert = inputEl.hasAttribute('inert');
const oldDisplay = inputEl.style.display;
const oldVisibility = inputEl.style.visibility;
const oldPosition = inputEl.style.position;
const oldLeft = inputEl.style.left;
const oldTop = inputEl.style.top;
const oldWidth = inputEl.style.width;
const oldHeight = inputEl.style.height;
// 临时解封
inputEl.classList.remove('uploader-hidden');
inputEl.removeAttribute('aria-hidden');
if (oldInert) inputEl.removeAttribute('inert');
inputEl.style.display = 'block';
inputEl.style.visibility = 'visible';
inputEl.style.position = 'fixed';
inputEl.style.left = '-9999px';
inputEl.style.top = '0';
inputEl.style.width = '1px';
inputEl.style.height = '1px';
log('Alt 模式:已临时解封 input');
// 同步 click()
inputEl.click();
// 恢复函数
const restore = () => {
window.removeEventListener('focus', restore, true);
if (!inputEl.isConnected) return;
if (oldAriaHidden !== null) {
inputEl.setAttribute('aria-hidden', oldAriaHidden);
} else {
inputEl.removeAttribute('aria-hidden');
}
if (oldInert) {
inputEl.setAttribute('inert', '');
} else {
inputEl.removeAttribute('inert');
}
inputEl.style.display = oldDisplay;
inputEl.style.visibility = oldVisibility;
inputEl.style.position = oldPosition;
inputEl.style.left = oldLeft;
inputEl.style.top = oldTop;
inputEl.style.width = oldWidth;
inputEl.style.height = oldHeight;
if (hadHiddenClass) {
inputEl.classList.add('uploader-hidden');
}
log('Alt 模式:已恢复 input');
};
window.addEventListener('focus', restore, true);
setTimeout(restore, 1500);
}
return;
}
// 拦截并优先于站点脚本(捕获阶段+阻止默认+阻止冒泡)
e.preventDefault();
e.stopPropagation();
if (e.stopImmediatePropagation) e.stopImmediatePropagation();
log('原按钮被替换触发:打开自定义 overlay', { anchor });
makeOverlay(findInput);
};
// 捕获阶段监听,优先拿到 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');
// 传递查找函数而不是 input 引用
makeOverlay(findInput);
}
}, 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) {// 关键:隐藏前 blur,并防止后续 focus
if (document.activeElement === input) {
input.blur();
}
// 添加 focus 监听,防止隐藏的 input 被聚焦(仅在隐藏状态下才 blur)
input.addEventListener('focus', (e) => {
if (input.classList.contains('uploader-hidden')) {
log('隐藏状态下的 input 被聚焦,立即 blur');
input.blur();
}
}, true);
// 移除 aria-hidden(file input 是交互元素,不应该被 aria-hidden)
if (input.getAttribute('aria-hidden') === 'true') {
input.removeAttribute('aria-hidden');
log('移除 aria-hidden 属性');
}
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';
// 创建一个查找函数,每次都动态找到最新的 input
const findInput = () => {
if (input && input.parentElement && input.type === 'file') {
return input;
}
const inputs = document.querySelectorAll('input[type="file"]');
return inputs.length > 0 ? inputs[inputs.length - 1] : null;
};
btn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
log('自定义上传按钮被点击');
makeOverlay(findInput);
});
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 });
});
}
})();
/**
* 为什么这个脚本能工作
*
* 核心问题:
* - file input 被页面的父容器 CSS 限制(clip/overflow/transform 等)
* - 即使 input 本身可见可交互,Chromium 仍会阻止 file picker
*
* 解决方案思路:
* 1. 临时把 input 挪到 document.body(绕过所有父容器限制)
* 2. 改样式为确实可交互:opacity 1, pointer-events auto, width/height auto
* 3. 在用户点击的手势中同步调用 input.click()(保持用户手势链路)
* 4. 用 window focus 事件监听文件选择器关闭,恢复原位置和样式
* 5. 设置 1.5s 兜底定时器,防止某些情况下 focus 事件没回来
*
* 为什么临时挪到 body 是关键:
* - 只改样式时,父容器的可见范围限制仍在,浏览器认为 input "隐藏"
* - 挪到 body 后,input 不受父容器约束,浏览器允许 file picker 弹出
*
* 其他改进:
* - 页面重建 input 时,动态查找最新的 input(不用缓存引用)
* - 隐藏 input 前 blur,隐藏后防止被 focus(避免 aria-hidden + focus 警告)
* - 高 z-index tooltip 在全局容器(不被页面元素覆盖)
* - Alt 按键和拖拽也采用同样的"临时解封 + 恢复"方案
*/