您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在网页中提供 Vim 风格操作:滚动(hjkl / d u / gg G)、链接提示(f/F)、查找(/ n/N)、历史(H/L)、页面操作(r t x*)、杂项(yy p o ?)。已针对 UserScript 权限做安全降级。
// ==UserScript== // @name Vim Style Navigation (with Settings + Enhanced) // @name:zh-CN Vim风格导航(带设置+增强模块) // @namespace https://www.bianwenbo.com/ // @version 2.2.0 // @author Wenbo Bian // @license MIT // @description Vim-style scrolling + hint(f/F), find(/ n/N), history(H/L), page ops(r t x*), misc(yy p o ?). UserScript-safe fallbacks. // @description:zh-CN 在网页中提供 Vim 风格操作:滚动(hjkl / d u / gg G)、链接提示(f/F)、查找(/ n/N)、历史(H/L)、页面操作(r t x*)、杂项(yy p o ?)。已针对 UserScript 权限做安全降级。 // @match http://*/* // @match https://*/* // @noframes // @run-at document-idle // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @grant GM_setClipboard // @grant GM_openInTab // ==/UserScript== (function () { 'use strict'; /** ======================= * 默认配置 * ======================= */ const DEFAULTS = { baseStep: 100, extraPage: 300, ggIntervalMs: 600, useNativeSmooth: true, ignoreWithModifier: true, excludeSites: [ 'https://docs.google.com/', 'https://mail.google.com/', /.*\.notion\.site\/.*/, 'https://www.notion.so/', 'https://www.figma.com/' ], hintChars: 'asdfghjklqwertyuiop', hintMaxLetters: 3, hintMinClickableSize: 10, hintZIndex: 2147483646, findBarHeight: 32, findBarOpacity: 0.96, helpWidth: 520, helpOpacity: 0.96, }; /** ======================= * 配置持久化 * ======================= */ function loadConfig() { const raw = GM_getValue('vimnav_config_v2', null); if (!raw) return structuredClone(DEFAULTS); const cfg = JSON.parse(raw); cfg.excludeSites = (cfg.excludeSites || []).map(item => { if (item && item.__type === 'RegExp') return new RegExp(item.source, item.flags || ''); return item; }); return { ...structuredClone(DEFAULTS), ...cfg }; } function saveConfig(cfg) { const st = structuredClone(cfg); st.excludeSites = st.excludeSites.map(p => p instanceof RegExp ? ({ __type:'RegExp', source:p.source, flags:p.flags||''}) : p); GM_setValue('vimnav_config_v2', JSON.stringify(st)); } let CONFIG = loadConfig(); /** ======================= * 菜单(可视化设置) * ======================= */ registerMenu(); function registerMenu() { GM_registerMenuCommand(`步长 baseStep(${CONFIG.baseStep})`, () => editInt('基础步长(>=1)', 'baseStep', 1)); GM_registerMenuCommand(`d/u 额外翻页 extraPage(${CONFIG.extraPage})`, () => editInt('额外翻页量(>=0)', 'extraPage', 0)); GM_registerMenuCommand(`gg 时间窗 ms(${CONFIG.ggIntervalMs})`, () => editInt('双击 g 时间窗(>=100ms)', 'ggIntervalMs', 100)); GM_registerMenuCommand(`切换 原生平滑(${CONFIG.useNativeSmooth?'开':'关'})`, () => toggle('useNativeSmooth')); GM_registerMenuCommand(`切换 忽略Ctrl/Alt/Meta(${CONFIG.ignoreWithModifier?'开':'关'})`, () => toggle('ignoreWithModifier')); GM_registerMenuCommand(`排除站点 管理`, manageExcludeSites); GM_registerMenuCommand(`Hint 字符集(${CONFIG.hintChars})`, () => editString('输入 Hint 标签字符集(不重复字符)', 'hintChars')); GM_registerMenuCommand(`Hint 标签最大长度(${CONFIG.hintMaxLetters})`, () => editInt('Hint 标签最大长度(1-4)', 'hintMaxLetters', 1, 4)); GM_registerMenuCommand(`恢复默认配置`, () => { if (confirm('确认恢复默认?')) { CONFIG = structuredClone(DEFAULTS); saveConfig(CONFIG); alert('OK'); } }); } function editInt(msg, key, min=Number.MIN_SAFE_INTEGER, max=Number.MAX_SAFE_INTEGER) { const v = prompt(msg, String(CONFIG[key])); if (v===null) return; const n = parseInt(v,10); if (!Number.isFinite(n) || n<min || n>max) return alert('无效数值'); CONFIG[key]=n; saveConfig(CONFIG); alert('已保存'); } function editString(msg, key) { const v = prompt(msg, String(CONFIG[key]||'')); if (v===null) return; CONFIG[key]=v; saveConfig(CONFIG); alert('已保存'); } function toggle(key) { CONFIG[key]=!CONFIG[key]; saveConfig(CONFIG); alert(`${key} = ${CONFIG[key]}`); } function manageExcludeSites() { const list = CONFIG.excludeSites.map((p,i)=>`${i}. ${p instanceof RegExp?`/${p.source}/${p.flags||''}`:p}`).join('\n') || '(空)'; const act = prompt('排除站点:\n'+list+'\n\n指令:add / del <idx> / clear / done', 'add'); if (act===null) return; const [cmd,arg] = act.trim().split(/\s+/); if (cmd==='add') { const s = prompt('输入前缀或正则(如 /.*\\.notion\\.site\\// )'); if (!s) return; const pat = parsePattern(s.trim()); if (!pat) return alert('格式无效'); CONFIG.excludeSites.push(pat); saveConfig(CONFIG); alert('已添加'); } else if (cmd==='del') { const i = parseInt(arg,10); if (!Number.isFinite(i) || i<0 || i>=CONFIG.excludeSites.length) return alert('索引无效'); CONFIG.excludeSites.splice(i,1); saveConfig(CONFIG); alert('已删除'); } else if (cmd==='clear') { if (!confirm('确认清空?')) return; CONFIG.excludeSites=[]; saveConfig(CONFIG); alert('已清空'); } } function parsePattern(s){ if (s.startsWith('/') && s.lastIndexOf('/')>0){ const last=s.lastIndexOf('/'); const pattern=s.slice(1,last); const flags=s.slice(last+1); try{return new RegExp(pattern,flags);}catch{ return null;} } return s; // 字符串前缀 } /** ======================= * 站点排除 * ======================= */ if (isExcluded(location.href, CONFIG.excludeSites)) return; function isExcluded(url, patterns){ try{ return patterns.some(p=> typeof p==='string'? url.startsWith(p) : p instanceof RegExp? p.test(url) : false ); }catch{ return false; } } /** ======================= * 工具函数 * ======================= */ const SA = CONFIG.baseStep, EXTRA = CONFIG.extraPage, GG_MS = CONFIG.ggIntervalMs; let lastGTime = 0; let hintMode = null; // {active, nodes, labelMap, filter, newTab} let findState = { active:false, term:'', bar:null, input:null, countSpan:null }; let helpOverlay = null; function scrollingEl() { return document.scrollingElement || document.documentElement || document.body; } function getScrollTop() { return scrollingEl().scrollTop; } function getScrollHeight(){ return scrollingEl().scrollHeight; } function isEditable(t){ if(!t) return false; const tag=t.tagName; if(tag==='INPUT'||tag==='TEXTAREA'||tag==='SELECT') return true; if(t.isContentEditable) return true; if (document.designMode && document.designMode.toLowerCase()==='on') return true; return false; } function smoothScrollTo(position){ const el=scrollingEl(); if (CONFIG.useNativeSmooth){ try{ el.scrollTo({top:position,left:0,behavior:'smooth'}); return; }catch{} } if (!window.requestAnimationFrame){ window.requestAnimationFrame = cb=>setTimeout(cb,17); } let current=el.scrollTop; (function step(){ const d=position-current; current=current+d/5; if (Math.abs(d)<1){ el.scrollTo(0,position); } else { el.scrollTo(0,current); requestAnimationFrame(step); } })(); } function smoothScrollBy(deltaY){ smoothScrollTo(getScrollTop()+deltaY); } function toTop(){ smoothScrollTo(0); } function toBottom(){ smoothScrollTo(getScrollHeight()); } function openInNewTab(url){ try{ GM_openInTab ? GM_openInTab(url, {active:false}) : window.open(url,'_blank'); }catch{ window.open(url,'_blank'); } } function copyToClipboard(text){ try{ if (typeof GM_setClipboard==='function'){ GM_setClipboard(text); return true; } }catch{} if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(text).catch(()=>{}); return true; } return false; } async function readFromClipboard(){ if (navigator.clipboard && navigator.clipboard.readText){ try{ const t=await navigator.clipboard.readText(); return t||''; }catch{} } const t=prompt('无法直接读取剪贴板,请粘贴要打开的 URL:',''); return t||''; } function isLikelyUrl(s){ try{ new URL(s); return true; }catch{} if (/^[a-z]+:\/\/\S+/i.test(s)) return true; if (/^[\w.-]+\.[a-z]{2,}([/:?#].*)?$/i.test(s)) return true; return false; } /** ======================= * 1) 链接提示(f/F) * ======================= */ function enterHintMode(newTab){ if (hintMode && hintMode.active) return; hintMode = { active:true, nodes:[], labelMap:new Map(), filter:'', newTab: !!newTab }; const layer = document.createElement('div'); layer.id='__vimnav_hint_layer__'; Object.assign(layer.style, { position:'fixed', inset:'0', zIndex:String(CONFIG.hintZIndex), pointerEvents:'none' }); document.documentElement.appendChild(layer); const clickable = collectClickable(); const labels = assignLabels(clickable.length, CONFIG.hintChars, CONFIG.hintMaxLetters); clickable.forEach((node, i)=>{ const rect = node.getBoundingClientRect(); if (rect.width<CONFIG.hintMinClickableSize && rect.height<CONFIG.hintMinClickableSize) return; const tag = renderHintTag(labels[i], rect); layer.appendChild(tag); hintMode.nodes.push({ node, label: labels[i], tag }); hintMode.labelMap.set(labels[i], node); }); if (hintMode.nodes.length===0){ exitHintMode(); return; } } function exitHintMode(){ const layer=document.getElementById('__vimnav_hint_layer__'); if (layer && layer.parentNode) layer.parentNode.removeChild(layer); hintMode = null; } function collectClickable(){ const sel = [ 'a[href]', 'button', 'summary', '[role="button"]', '[onclick]', 'input[type="submit"]', 'input[type="button"]', 'input[type="image"]', 'area[href]' ].join(','); const nodes = Array.from(document.querySelectorAll(sel)).filter(el=>{ const r=el.getBoundingClientRect(); const style = getComputedStyle(el); const visible = r.width>0 && r.height>0 && style.visibility!=='hidden' && style.opacity!=='0'; return visible; }); return Array.from(new Set(nodes)); } function assignLabels(n, chars, maxLen){ const res=[]; const base = chars.length; let length = 1; let capacity = base; while (capacity<n && length<maxLen){ length++; capacity *= base; } for (let i=0;i<n;i++) res.push(encodeIndex(i, base, length, chars)); return res; } function encodeIndex(idx, base, length, chars){ let s = ''; for (let i=0;i<length;i++){ s = chars[idx % base] + s; idx = Math.floor(idx / base); } return s; } function renderHintTag(label, rect){ const el = document.createElement('div'); el.textContent = label; Object.assign(el.style, { position:'fixed', left: (Math.max(0, rect.left)+window.scrollX)+'px', top: (Math.max(0, rect.top) +window.scrollY)+'px', font:'12px/1 monospace', padding:'2px 4px', borderRadius:'4px', background:'rgba(0,0,0,0.85)', color:'#fff', boxShadow:'0 1px 3px rgba(0,0,0,.3)', pointerEvents:'none', userSelect:'none' }); return el; } function handleHintInput(ch){ if (!hintMode || !hintMode.active) return; if (!CONFIG.hintChars.includes(ch)) return; // 仅接受 hint 字符 hintMode.filter += ch; const candidates = hintMode.nodes.filter(x=> x.label.startsWith(hintMode.filter)); hintMode.nodes.forEach(x=>{ x.tag.style.opacity = x.label.startsWith(hintMode.filter) ? '1' : '0.15'; }); if (candidates.length===1 && candidates[0].label===hintMode.filter){ const target = candidates[0].node; const href = target.getAttribute('href'); const click = ()=> target.dispatchEvent(new MouseEvent('click', {bubbles:true, cancelable:true, view:window})); if (hintMode.newTab){ if (href) openInNewTab(new URL(href, location.href).href); else openInNewTab(location.href); } else { click(); } exitHintMode(); } } /** ======================= * 2) 查找(/,n/N) * ======================= */ function toggleFindBar(show){ if (show){ if (findState.active) return; findState.active = true; const bar = document.createElement('div'); Object.assign(bar.style, { position:'fixed', left:'50%', top:'0', transform:'translateX(-50%)', height:CONFIG.findBarHeight+'px', lineHeight:CONFIG.findBarHeight+'px', background:`rgba(20,20,20,${CONFIG.findBarOpacity})`, color:'#fff', padding:'0 8px', borderRadius:'0 0 8px 8px', zIndex:String(CONFIG.hintZIndex), display:'flex', gap:'8px', alignItems:'center', font:'13px/1 system-ui,Arial,Helvetica,sans-serif' }); const label = document.createElement('span'); label.textContent='/'; const input = document.createElement('input'); Object.assign(input.style, { width:'320px', height:'22px', outline:'none', border:'none', borderRadius:'4px', padding:'0 6px' }); input.placeholder='Type to search... Enter=next Shift+Enter=prev Esc=close'; const count = document.createElement('span'); count.textContent='…'; bar.append(label, input, count); document.documentElement.appendChild(bar); findState.bar=bar; findState.input=input; findState.countSpan=count; findState.term=''; input.addEventListener('keydown', e=>{ if (e.key==='Enter'){ e.preventDefault(); performFind(input.value, !e.shiftKey); } else if (e.key==='Escape'){ e.preventDefault(); closeFindBar(); } }); setTimeout(()=>input.focus(), 0); } else { closeFindBar(); } } function closeFindBar(){ if (!findState.active) return; findState.active=false; if (findState.bar && findState.bar.parentNode) findState.bar.parentNode.removeChild(findState.bar); findState={ active:false, term:'', bar:null, input:null, countSpan:null }; } function performFind(term, forward=true){ if (!term) return; findState.term = term; const found = window.find(term, false, !forward, true, false, false, false); if (findState.countSpan) findState.countSpan.textContent = found ? '√' : '0/0'; } function findNext(){ if (!findState.term) return; window.find(findState.term, false, false, true, false, false, false); } function findPrev(){ if (!findState.term) return; window.find(findState.term, false, true, true, false, false, false); } /** ======================= * 6) 帮助(?) * ======================= */ function toggleHelp(show){ if (show){ if (helpOverlay) return; const box = document.createElement('div'); Object.assign(box.style, { position:'fixed', left:'50%', top:'10%', transform:'translateX(-50%)', width: CONFIG.helpWidth+'px', maxWidth:'90vw', background:`rgba(20,20,20,${CONFIG.helpOpacity})`, color:'#fff', padding:'12px 16px', borderRadius:'12px', zIndex:String(CONFIG.hintZIndex), font:'13px/1.45 system-ui,Arial,Helvetica,sans-serif' }); box.innerHTML = ` <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"> <b>Vim Style Navigation</b> <span style="opacity:.7">Press <kbd>?</kbd> to close</span> </div> <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;"> <div> <b>Scroll</b><br/> <code>j/k</code> ↓/↑ <code>h/l</code> ←/→<br/> <code>d/u</code> page ↓/↑ (base+extra)<br/> <code>gg</code> top <code>G</code> bottom </div> <div> <b>Hint</b><br/> <code>f</code> open link <code>F</code> open in new tab </div> <div> <b>Find</b><br/> <code>/</code> open find <code>n/N</code> next/prev </div> <div> <b>History</b><br/> <code>H</code> back <code>L</code> forward </div> <div> <b>Page</b><br/> <code>r</code> reload <code>t</code> new tab<br/> <code>x</code> close tab <i>(only if opened by script)</i><br/> <code>J/K</code> switch tab — <i>not available in UserScript</i> </div> <div> <b>Misc</b><br/> <code>yy</code> copy page URL<br/> <code>p</code> open URL from clipboard<br/> <code>o</code> open URL or search </div> </div> `; document.documentElement.appendChild(box); helpOverlay = box; } else { if (helpOverlay && helpOverlay.parentNode) helpOverlay.parentNode.removeChild(helpOverlay); helpOverlay = null; } } /** ======================= * 键盘处理 * ======================= */ window.addEventListener('keydown', onKeyDown, { capture:false }); function onKeyDown(e){ if (e.defaultPrevented) return; // Hint 模式优先 if (hintMode && hintMode.active){ if (e.key==='Escape'){ exitHintMode(); e.preventDefault(); return; } if (!e.ctrlKey && !e.altKey && !e.metaKey && e.key.length===1){ handleHintInput(e.key.toLowerCase()); e.preventDefault(); return; } return; } // 查找条激活时,交给输入框 if (findState.active && findState.input && document.activeElement === findState.input){ return; } const inEditable = isEditable(e.target); // 若开启“忽略修饰键”,带修饰键则不处理 if (CONFIG.ignoreWithModifier && (e.ctrlKey || e.altKey || e.metaKey)) return; if (inEditable) return; const k = e.key; // 基础滚动与跳转 switch (k){ case 'h': smoothScrollBy(-SA); e.preventDefault(); return; case 'j': smoothScrollBy( SA); e.preventDefault(); return; case 'k': smoothScrollBy(-SA); e.preventDefault(); return; case 'l': smoothScrollBy( SA); e.preventDefault(); return; case 'd': smoothScrollBy( SA + EXTRA); e.preventDefault(); return; case 'u': smoothScrollBy(-(SA + EXTRA)); e.preventDefault(); return; case 'g': { const now=Date.now(); if (now - lastGTime <= GG_MS){ toTop(); lastGTime=0; e.preventDefault(); } else { lastGTime=now; } return; } case 'G': toBottom(); e.preventDefault(); return; } // 增强模块 switch (k){ // Hint case 'f': enterHintMode(false); e.preventDefault(); return; case 'F': enterHintMode(true); e.preventDefault(); return; // Find case '/': toggleFindBar(true); e.preventDefault(); return; case 'n': findNext(); e.preventDefault(); return; case 'N': findPrev(); e.preventDefault(); return; // Page ops case 'r': location.reload(); e.preventDefault(); return; case 't': openInNewTab('about:blank'); e.preventDefault(); return; case 'x': window.close(); e.preventDefault(); return; // 仅脚本打开的标签可关 // History case 'H': history.back(); e.preventDefault(); return; case 'L': history.forward(); e.preventDefault(); return; // Help case '?': toggleHelp(!helpOverlay); e.preventDefault(); return; // URL/搜索 case 'o': { const s = prompt('输入 URL 或搜索词:',''); if (s==null || !s.trim()) return; const text = s.trim(); if (isLikelyUrl(text)){ location.href = text.match(/^https?:\/\//i) ? text : ('https://' + text); } else { location.href = 'https://www.google.com/search?q=' + encodeURIComponent(text); } e.preventDefault(); return; } case 'y': break; // 交给 yy 组合 case 'p': (async () => { const t = await readFromClipboard(); if (!t) return; const text = t.trim(); if (!text) return; if (isLikelyUrl(text)){ location.href = text.match(/^https?:\/\//i) ? text : ('https://' + text); } else { location.href = 'https://www.google.com/search?q=' + encodeURIComponent(text); } })(); e.preventDefault(); return; } // 组合键序列(yy) if (k==='y'){ if (onKeyDown.__lastY && (Date.now()-onKeyDown.__lastY)<=500){ onKeyDown.__lastY=0; const ok = copyToClipboard(location.href); if (!ok) alert(location.href); e.preventDefault(); return; } onKeyDown.__lastY = Date.now(); } else { onKeyDown.__lastY = 0; } lastGTime=0; } // 失焦/点击时的清理 window.addEventListener('blur', ()=>{ lastGTime=0; onKeyDown.__lastY=0; }); window.addEventListener('mousedown', ()=>{ if (hintMode && hintMode.active) exitHintMode(); }, {capture:true}); })();