// ==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});
})();