// ==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 按键和拖拽也采用同样的"临时解封 + 恢复"方案 */