// ==UserScript== // @name 强化 W/S 滚动(支持多按键配置)+ E/Q 回顶底 + A 后退 + D 前进 // @namespace http://tampermonkey.net/ // @version 2.2 // @description W/S 上下滚动,E 回顶部,Q 到底部,A 后退,D 前进。支持多按键配置,改进必应搜索自动聚焦问题,支持站点白名单、iframe 注入、捕获阶段拦截与短时抑制聚焦策略。 // @author ChatGPT // @match *://*/* // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // ---------------- 配置 ---------------- const DEFAULT_DISTANCE = 100; const DEFAULT_SMOOTH = true; const DEFAULT_SHIFT_MULT = 3; const RECENT_TYPING_MS = 1500; // 若在此时间内检测到输入,则认为用户在输入,脚本不拦截 const FOCUS_SUPPRESS_MS = 600; // 在拦截键后,短时间内 suppress focusin 以避免页面强制聚焦回输入框 const STORAGE_PREFIX = 'tm_scroll_enhanced_v2_'; // 默认按键配置 const DEFAULT_KEY_CONFIG = { KEY_UP: ['w'], KEY_DOWN: ['s'], KEY_TOP: ['q'], KEY_BOTTOM: ['e'], KEY_BACK: ['a'], KEY_FORWARD: ['d'] // 新增前进键 }; // 可以通过修改这里来更改配置 const CUSTOM_KEY_CONFIG = { KEY_UP: ['w', '↑', 'PageUp'], // 上滚动 KEY_DOWN: ['s', '↓', 'PageDown'], // 下滚动 KEY_TOP: ['q', 'Home'], // 回到顶部 KEY_BOTTOM: ['e', 'End'], // 回到底部 KEY_BACK: ['a', '←'], // 后退功能 KEY_FORWARD: ['d', '→'] // 前进功能 }; // 默认站点白名单(对这些站点放宽"可编辑元素"的阻止策略) const alwaysAllowHosts = [ 'sc.ttdahuo.com', 'bing.com' ]; // 初始化键位配置 - 支持用户配置或默认 let keyConfig = { KEY_UP: getSetting('key_up', DEFAULT_KEY_CONFIG.KEY_UP), KEY_DOWN: getSetting('key_down', DEFAULT_KEY_CONFIG.KEY_DOWN), KEY_TOP: getSetting('key_top', DEFAULT_KEY_CONFIG.KEY_TOP), KEY_BOTTOM: getSetting('key_bottom', DEFAULT_KEY_CONFIG.KEY_BOTTOM), KEY_BACK: getSetting('key_back', DEFAULT_KEY_CONFIG.KEY_BACK), KEY_FORWARD: getSetting('key_forward', DEFAULT_KEY_CONFIG.KEY_FORWARD) // 新增前进配置 }; // ---------------- 存取配置 ---------------- function getSetting(key, def) { const v = GM_getValue(STORAGE_PREFIX + key); return v === undefined ? def : v; } function setSetting(key, val) { GM_setValue(STORAGE_PREFIX + key, val); } function getDistance() { return parseInt(getSetting('distance', DEFAULT_DISTANCE), 10) || DEFAULT_DISTANCE; } function isSmooth() { return !!getSetting('smooth', DEFAULT_SMOOTH); } function getShiftMult() { return parseInt(getSetting('shiftMult', DEFAULT_SHIFT_MULT), 10) || DEFAULT_SHIFT_MULT; } // ---------------- typing 检测 ---------------- let lastTypingTime = 0; function markTyping() { lastTypingTime = Date.now(); } function recentlyTyping() { return (Date.now() - lastTypingTime) < RECENT_TYPING_MS; } // ---------------- 编辑元素与搜索输入判断 ---------------- function isEditableElement(el) { if (!el) return false; const tag = (el.tagName || '').toLowerCase(); if (tag === 'input' || tag === 'textarea' || tag === 'select') return true; if (el.isContentEditable) return true; const role = el.getAttribute && el.getAttribute('role'); if (role && role.toLowerCase().includes('textbox')) return true; return false; } function isLikelySearchInput(el) { if (!el || el.nodeType !== 1) return false; const tag = (el.tagName || '').toLowerCase(); if (tag !== 'input') return false; const type = (el.type || '').toLowerCase(); if (type === 'search') return true; const attrs = [ el.id || '', el.name || '', el.getAttribute('aria-label') || '', el.getAttribute('placeholder') || '', el.className || '' ].join(' ').toLowerCase(); if (attrs.match(/\b(search|q|query|sb_form_q|searchbox|search-input|s)\b/)) return true; const role = el.getAttribute && (el.getAttribute('role') || '').toLowerCase(); if (role === 'combobox') return true; try { const host = location.host || ''; if ((host.includes('bing.com') || host.includes('google.com') || host.includes('baidu.com'))) { const rect = el.getBoundingClientRect(); if (rect && rect.top >= 0 && rect.top < window.innerHeight * 0.35) { return true; } } } catch (e) {} return false; } // ---------------- 可滚动容器识别 ---------------- function isScrollable(el) { if (!el || el.nodeType !== 1) return false; const style = window.getComputedStyle(el); const overflowY = style.overflowY; const canScroll = (overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay'); const scrollableSize = el.scrollHeight > el.clientHeight + 1; return canScroll && scrollableSize; } function findScrollAncestor(start) { let el = start; while (el && el !== document.documentElement && el !== document.body) { if (isScrollable(el)) return el; el = el.parentElement; } return document.scrollingElement || document.documentElement || document.body; } function pickScrollableContainer(evt) { try { const active = document.activeElement; if (active) { const a = findScrollAncestor(active); if (a) return a; } const t = evt && evt.target; if (t && t !== document && t !== document.documentElement) { const b = findScrollAncestor(t); if (b) return b; } const cx = Math.floor(window.innerWidth / 2); const cy = Math.floor(window.innerHeight / 2); const mid = document.elementFromPoint(cx, cy); if (mid) { const m = findScrollAncestor(mid); if (m) return m; } } catch (e) {} return document.scrollingElement || document.documentElement || document.body; } // ---------------- 滚动执行 ---------------- function doScroll(container, deltaY, smooth) { if (!container) return; const isDoc = (container === document.scrollingElement || container === document.documentElement || container === document.body); const behavior = smooth ? 'smooth' : 'auto'; try { if (isDoc) window.scrollBy({ top: deltaY, left: 0, behavior }); else container.scrollBy({ top: deltaY, left: 0, behavior }); } catch (e) { if (isDoc) window.scrollBy(0, deltaY); else container.scrollTop += deltaY; } } function scrollToTop(container, smooth) { const isDoc = (container === document.scrollingElement || container === document.documentElement || container === document.body); const behavior = smooth ? 'smooth' : 'auto'; try { if (isDoc) window.scrollTo({ top: 0, left: 0, behavior }); else container.scrollTo({ top: 0, left: 0, behavior }); } catch (e) { if (isDoc) window.scrollTo(0, 0); else container.scrollTop = 0; } } function scrollToBottom(container, smooth) { const isDoc = (container === document.scrollingElement || container === document.documentElement || container === document.body); const behavior = smooth ? 'smooth' : 'auto'; try { if (isDoc) { const bottom = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) - window.innerHeight; window.scrollTo({ top: bottom, left: 0, behavior }); } else { const bottom = container.scrollHeight - container.clientHeight; container.scrollTo({ top: bottom, left: 0, behavior }); } } catch (e) { if (isDoc) { const bottom = Math.max(document.documentElement.scrollHeight, document.body.scrollHeight) - window.innerHeight; window.scrollTo(0, bottom); } else container.scrollTop = container.scrollHeight - container.clientHeight; } } // ---------------- 后退功能 ---------------- function goBack() { try { // 如果有历史记录则后退 if (window.history.length > 1) { window.history.back(); } else { // 否则退回到最开始的页面 if (window.location.href !== window.location.origin) { window.location.href = window.location.origin; } } } catch (e) { console.warn('后退功能不可用:', e); } } // ---------------- 前进功能 ---------------- function goForward() { try { // 如果有前进历史记录则前进 if (window.history.length > 1 && window.history.forward) { window.history.forward(); } else { // 如果没有前进记录,可以尝试重新访问当前页面 // 或者什么也不做 console.log('没有前进历史记录'); } } catch (e) { console.warn('前进功能不可用:', e); } } // ---------------- 焦点抑制(处理必应等页面在按键后把焦点移回输入框的问题) ---------------- let suppressFocusUntil = 0; function activateFocusSuppress() { suppressFocusUntil = Date.now() + FOCUS_SUPPRESS_MS; } function isSuppressingFocus() { return Date.now() < suppressFocusUntil; } // focusin 捕获:若在抑制期并且目标为输入类元素,则 blur 掉(阻止页面强制聚焦) function onFocusInCapture(evt) { if (!isSuppressingFocus()) return; const tgt = evt.target; if (!tgt || tgt.nodeType !== 1) return; try { if (isEditableElement(tgt) || tgt.tagName && tgt.tagName.toLowerCase() === 'input') { // 尝试 blur,并阻止后续 focus 处理 try { tgt.blur(); } catch (e) {} evt.stopImmediatePropagation(); evt.preventDefault && evt.preventDefault(); } } catch (e) {} } // ---------------- 决定是否忽略编辑元素(尊重用户输入,但允许对"搜索输入"拦截以便滚动) ---------------- function hostIsAlwaysAllow() { try { const host = location.host || ''; for (const h of alwaysAllowHosts) { if (host.includes(h)) return true; } } catch (e) {} return false; } function shouldIgnoreForEditing(evt) { const tgt = evt.target; if (!isEditableElement(tgt)) return false; // 如果最近用户有真实输入,尊重输入 if (recentlyTyping()) return true; // 如果该元素可能是搜索输入或者当前站点在白名单,则允许拦截(返回 false) if (isLikelySearchInput(tgt)) return false; if (hostIsAlwaysAllow()) return false; // 其他可编辑元素不拦截 return true; } // ---------------- 键名标准化处理 ---------------- function normalizeKeyName(key) { if (!key) return key; // 统一处理方向键和功能键的命名 switch (key.toLowerCase()) { case 'arrowup': return '↑'; case 'arrowdown': return '↓'; case 'arrowleft': return '←'; case 'arrowright': return '→'; case 'pageup': return 'PageUp'; case 'pagedown': return 'PageDown'; case 'home': return 'Home'; case 'end': return 'End'; case 'forward': return 'Forward'; default: return key.toLowerCase(); } } // ---------------- 主键事件处理(capture 阶段) ---------------- function handleKeyEventCapture(evt) { // 忽略复合修饰键 if (evt.ctrlKey || evt.altKey || evt.metaKey) return; // 如果在输入且应忽略则返回 if (shouldIgnoreForEditing(evt)) return; const key = normalizeKeyName(evt.key); if (!key) return; // 检查配置的按键是否匹配 if (!keyConfig.KEY_UP.includes(key) && !keyConfig.KEY_DOWN.includes(key) && !keyConfig.KEY_TOP.includes(key) && !keyConfig.KEY_BOTTOM.includes(key) && !keyConfig.KEY_BACK.includes(key) && !keyConfig.KEY_FORWARD.includes(key)) return; const baseDistance = getDistance(); const mult = evt.shiftKey ? getShiftMult() : 1; const distance = baseDistance * mult; const smooth = isSmooth(); const container = pickScrollableContainer(evt); // 底层页面可能在 keydown/keyup/keypress 任意阶段处理聚焦,我们在 capture 阶段尽量阻止页面后续处理 // 仅对我们处理的按键进行完全拦截 if (keyConfig.KEY_UP.includes(key)) { // 防止页面对该键的任何后续处理(包括聚焦) try { evt.stopImmediatePropagation(); evt.stopPropagation(); } catch (e) {} try { evt.preventDefault(); } catch (e) {} // 激活短时焦点抑制,防止页面在稍后将焦点移回输入框 activateFocusSuppress(); doScroll(container, -distance, smooth); } else if (keyConfig.KEY_DOWN.includes(key)) { // 防止页面对该键的任何后续处理(包括聚焦) try { evt.stopImmediatePropagation(); evt.stopPropagation(); } catch (e) {} try { evt.preventDefault(); } catch (e) {} // 激活短时焦点抑制,防止页面在稍后将焦点移回输入框 activateFocusSuppress(); doScroll(container, distance, smooth); } else if (keyConfig.KEY_TOP.includes(key)) { // 防止页面对该键的任何后续处理(包括聚焦) try { evt.stopImmediatePropagation(); evt.stopPropagation(); } catch (e) {} try { evt.preventDefault(); } catch (e) {} // 激活短时焦点抑制,防止页面在稍后将焦点移回输入框 activateFocusSuppress(); scrollToTop(container, smooth); } else if (keyConfig.KEY_BOTTOM.includes(key)) { // 防止页面对该键的任何后续处理(包括聚焦) try { evt.stopImmediatePropagation(); evt.stopPropagation(); } catch (e) {} try { evt.preventDefault(); } catch (e) {} // 激活短时焦点抑制,防止页面在稍后将焦点移回输入框 activateFocusSuppress(); scrollToBottom(container, smooth); } else if (keyConfig.KEY_BACK.includes(key)) { // 防止页面对该键的任何后续处理(包括聚焦) try { evt.stopImmediatePropagation(); evt.stopPropagation(); } catch (e) {} try { evt.preventDefault(); } catch (e) {} // 激活短时焦点抑制 activateFocusSuppress(); goBack(); } else if (keyConfig.KEY_FORWARD.includes(key)) { // 防止页面对该键的任何后续处理(包括聚焦) try { evt.stopImmediatePropagation(); evt.stopPropagation(); } catch (e) {} try { evt.preventDefault(); } catch (e) {} // 激活短时焦点抑制 activateFocusSuppress(); goForward(); } } // 也保留在 bubble/普通阶段的处理作为后备(兼容性) function handleKeyEventBubble(evt) { // 仅在非编辑或允许的情况下处理(不重复阻止 propagation) if (evt.defaultPrevented) return; if (evt.ctrlKey || evt.altKey || evt.metaKey) return; if (shouldIgnoreForEditing(evt)) return; const key = normalizeKeyName(evt.key); if (!key) return; // 检查配置的按键是否匹配 if (!keyConfig.KEY_UP.includes(key) && !keyConfig.KEY_DOWN.includes(key) && !keyConfig.KEY_TOP.includes(key) && !keyConfig.KEY_BOTTOM.includes(key) && !keyConfig.KEY_BACK.includes(key) && !keyConfig.KEY_FORWARD.includes(key)) return; const baseDistance = getDistance(); const mult = evt.shiftKey ? getShiftMult() : 1; const distance = baseDistance * mult; const smooth = isSmooth(); const container = pickScrollableContainer(evt); try { evt.preventDefault(); } catch (e) {} if (keyConfig.KEY_UP.includes(key)) doScroll(container, -distance, smooth); else if (keyConfig.KEY_DOWN.includes(key)) doScroll(container, distance, smooth); else if (keyConfig.KEY_TOP.includes(key)) scrollToTop(container, smooth); else if (keyConfig.KEY_BOTTOM.includes(key)) scrollToBottom(container, smooth); else if (keyConfig.KEY_BACK.includes(key)) goBack(); else if (keyConfig.KEY_FORWARD.includes(key)) goForward(); activateFocusSuppress(); } // ---------------- typing 事件记录 ---------------- function onInputEvent(evt) { if (isEditableElement(evt.target)) markTyping(); } function onKeyDownForTyping(evt) { const tgt = evt.target; if (!isEditableElement(tgt)) return; const k = evt.key || ''; if (k.length === 1 && !evt.ctrlKey && !evt.altKey && !evt.metaKey) { markTyping(); } } // ---------------- 将监听注入到指定 window(支持 iframe) ---------------- function addListenersToWindow(win) { try { if (!win || win._tm_scroll_enhanced_attached) return; win._tm_scroll_enhanced_attached = true; // capture 阶段拦截 keydown/keypress/keyup win.document.addEventListener('keydown', handleKeyEventCapture, { capture: true, passive: false }); win.document.addEventListener('keypress', handleKeyEventCapture, { capture: true, passive: false }); win.document.addEventListener('keyup', handleKeyEventCapture, { capture: true, passive: false }); // bubble/back-up win.document.addEventListener('keydown', handleKeyEventBubble, { capture: false, passive: false }); win.addEventListener('keydown', handleKeyEventBubble, { capture: false, passive: false }); // focus suppression win.document.addEventListener('focusin', onFocusInCapture, { capture: true, passive: false }); // typing detection win.document.addEventListener('input', onInputEvent, { capture: true, passive: true }); win.document.addEventListener('keydown', onKeyDownForTyping, { capture: true, passive: true }); } catch (e) { // 跨域 iframe 可能抛错,忽略 } } // 注入当前 window addListenersToWindow(window); // 注入到所有同源 iframe function attachToAllIframes() { const iframes = Array.from(document.getElementsByTagName('iframe')); for (const frame of iframes) { try { if (!frame.contentWindow) continue; addListenersToWindow(frame.contentWindow); } catch (e) {} } } attachToAllIframes(); // 监测新增 iframe const mo = new MutationObserver((records) => { for (const r of records) { for (const n of r.addedNodes) { if (!n) continue; if (n.tagName && n.tagName.toLowerCase() === 'iframe') { setTimeout(() => { try { addListenersToWindow(n.contentWindow); } catch (e) {} }, 200); } else if (n.querySelectorAll) { const frames = n.querySelectorAll('iframe'); frames.forEach(f => setTimeout(() => { try { addListenersToWindow(f.contentWindow); } catch(e) {} }, 200)); } } } }); try { mo.observe(document.documentElement || document, { childList: true, subtree: true }); } catch (e) {} // ---------------- 油猴菜单 ---------------- function registerMenu() { GM_registerMenuCommand(`设置每次滚动距离(当前 ${getDistance()} px)`, () => { const input = prompt('请输入每次滚动距离(正整数,单位 px):', String(getDistance())); if (input === null) return; const v = parseInt(input.trim(), 10); if (isNaN(v) || v <= 0) { alert('请输入正整数'); return; } setSetting('distance', v); alert('已保存,刷新页面或重新打开菜单可看到更新。'); }); GM_registerMenuCommand(`${isSmooth() ? '关闭' : '启用'}平滑滚动(当前 ${isSmooth() ? '开启' : '关闭'})`, () => { setSetting('smooth', !isSmooth()); alert('设置已切换,刷新页面使菜单显示更新。'); }); GM_registerMenuCommand(`设置 Shift 放大倍数(当前 x${getShiftMult()})`, () => { const input = prompt('按住 Shift 时的步进倍数(正整数,例如 2,3...)', String(getShiftMult())); if (input === null) return; const v = parseInt(input.trim(), 10); if (isNaN(v) || v <= 0) { alert('请输入正整数'); return; } setSetting('shiftMult', v); alert('已保存,刷新页面或重新打开菜单可看到更新。'); }); // 提供一个更稳定的菜单交互方式 GM_registerMenuCommand('自定义快捷键设置【说明】', () => { // 显示设置说明 const instructions = `=== 自定义快捷键设置说明 === 方法1:通过编辑脚本代码直接修改(推荐) 1. 点击Tampermonkey扩展图标 2. 找到本脚本并点击"编辑" 3. 找到 CUSTOM_KEY_CONFIG 对象并修改配置 4. 保存后刷新页面生效 方法2:通过配置参数设置(官方推荐) 当前配置: 上滚动: ${keyConfig.KEY_UP.join(', ')} 下滚动: ${keyConfig.KEY_DOWN.join(', ')} 回到顶部: ${keyConfig.KEY_TOP.join(', ')} 回到底部: ${keyConfig.KEY_BOTTOM.join(', ')} 后退功能: ${keyConfig.KEY_BACK.join(', ')} 前进功能: ${keyConfig.KEY_FORWARD.join(', ')} 配置示例: CUSTOM_KEY_CONFIG = { KEY_UP: ['w', '↑', 'PageUp'], KEY_DOWN: ['s', '↓', 'PageDown'], KEY_TOP: ['e', 'Home'], KEY_BOTTOM: ['q', 'End'], KEY_BACK: ['a'], // 后退键 KEY_FORWARD: ['d'] // 前进键 }; 支持的按键类型: - W, S, E, Q, A, D 等标准键盘字母 - ↑, ↓, ←, → 等方向键 - PageUp, PageDown, Home, End 等功能键 方法3:重置为默认 选择"重置为默认值"命令 提示:每个按键必须为单字符或标准功能键`; alert(instructions); // 在控制台显示数据结构 console.log("当前按键配置信息:"); console.log("KEY_UP:", keyConfig.KEY_UP); console.log("KEY_DOWN:", keyConfig.KEY_DOWN); console.log("KEY_TOP:", keyConfig.KEY_TOP); console.log("KEY_BOTTOM:", keyConfig.KEY_BOTTOM); console.log("KEY_BACK:", keyConfig.KEY_BACK); console.log("KEY_FORWARD:", keyConfig.KEY_FORWARD); }); // 重置为默认值功能 GM_registerMenuCommand('重置为默认值', () => { setSetting('distance', DEFAULT_DISTANCE); setSetting('smooth', DEFAULT_SMOOTH); setSetting('shiftMult', DEFAULT_SHIFT_MULT); setSetting('key_up', DEFAULT_KEY_CONFIG.KEY_UP); setSetting('key_down', DEFAULT_KEY_CONFIG.KEY_DOWN); setSetting('key_top', DEFAULT_KEY_CONFIG.KEY_TOP); setSetting('key_bottom', DEFAULT_KEY_CONFIG.KEY_BOTTOM); setSetting('key_back', DEFAULT_KEY_CONFIG.KEY_BACK); setSetting('key_forward', DEFAULT_KEY_CONFIG.KEY_FORWARD); keyConfig = DEFAULT_KEY_CONFIG; alert('已重置为默认设置。'); }); GM_registerMenuCommand('在当前站点强制开启(白名单)/取消(仅当前 host)', () => { try { const host = location.host || ''; let added = false; for (let i = 0; i < alwaysAllowHosts.length; i++) { if (alwaysAllowHosts[i] === host) { // 已存在 -> 移除 alwaysAllowHosts.splice(i, 1); alert(`已从脚本白名单移除 ${host}(刷新生效)`); return; } } alwaysAllowHosts.push(host); alert(`已将 ${host} 添加到脚本白名单(刷新生效)`); } catch (e) {} }); } try { registerMenu(); } catch (e) {} // 初始化时从自定义配置对象更新实际的键配置(优先使用配置) keyConfig = CUSTOM_KEY_CONFIG; console.log('[tm-scroll-enhanced-v2] 已启动:W/S 上下,E 顶部,Q 底部,A 后退,D 前进。已增强 capture 拦截与焦点抑制策略,及支持多按键配置。'); })();