85 lines
5.4 KiB
TypeScript
85 lines
5.4 KiB
TypeScript
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
|
import 'monaco-editor/esm/vs/editor/contrib/wordOperations/browser/wordOperations';
|
|
import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/browser/bracketMatching';
|
|
|
|
type Navigation = {
|
|
navigate: (kind: 'definition' | 'references' | 'callers' | 'hover', openFirst?: boolean) => Promise<void>;
|
|
step: (direction: number, count?: number) => Promise<void>;
|
|
back: () => Promise<void>;
|
|
};
|
|
// A reading-only key layer over Monaco's existing motions, search, and folding.
|
|
// No insert mode, editing operators, or separate text buffer.
|
|
export function installVim(editor: monaco.editor.IStandaloneCodeEditor, navigation: Navigation) {
|
|
let prefix = '', count = '', repeat: 'search' | 'locations' = 'search';
|
|
const indicator = document.createElement('span'); indicator.id = 'vim-status';
|
|
indicator.title = 'Vim reading: hjkl, w/b/e, gg/G, gd definition, gc callers, gr references, n/N next/previous, / search, Ctrl+o back, za fold. No insert mode.';
|
|
document.querySelector('footer')!.insertBefore(indicator, document.getElementById('cursor'));
|
|
const update = () => { indicator.textContent = `VIM · READ ONLY${count || prefix ? ` · ${count}${prefix}` : ''} · n: ${repeat}`; };
|
|
const clear = () => { prefix = ''; count = ''; update(); };
|
|
const trigger = (id: string, amount = 1) => { for (let i = 0; i < amount; i++) editor.trigger('vim', id, null); };
|
|
editor.onDidBlurEditorText(clear);
|
|
editor.onDidChangeModel(clear);
|
|
update();
|
|
const root = editor.getContainerDomNode();
|
|
root.addEventListener('keydown', event => {
|
|
if (event.isComposing || event.altKey || event.metaKey) return;
|
|
// Find and other input widgets keep ordinary text entry and shortcuts.
|
|
if (!editor.hasTextFocus()) {
|
|
if ((event.key === 'Enter' || event.key === 'Escape') && (event.target as HTMLElement).closest('.find-widget')) {
|
|
event.preventDefault(); event.stopImmediatePropagation();
|
|
trigger('closeFindWidget'); editor.focus(); repeat = 'search'; clear();
|
|
}
|
|
return;
|
|
}
|
|
if (!editor.getModel()) return;
|
|
const key = event.key;
|
|
if (event.ctrlKey && !['d', 'u', 'f', 'b', 'o', '['].includes(key.toLowerCase())) return;
|
|
if (!event.ctrlKey && (key.length !== 1 && key !== 'Escape')) { clear(); return; }
|
|
event.preventDefault(); event.stopImmediatePropagation();
|
|
const amount = Math.min(Number(count) || 1, 1000);
|
|
if (event.ctrlKey) {
|
|
clear();
|
|
if (key === 'o') { void navigation.back(); return; }
|
|
if (key === '[') { trigger('cancelSelection'); return; }
|
|
const down = key === 'd' || key === 'f';
|
|
const lines = Math.max(1, Math.floor(editor.getLayoutInfo().height / editor.getOption(monaco.editor.EditorOption.lineHeight) / (key === 'd' || key === 'u' ? 2 : 1)));
|
|
editor.trigger('vim', 'cursorMove', { to: down ? 'down' : 'up', by: 'wrappedLine', value: Math.min(lines * amount, 100000) });
|
|
return;
|
|
}
|
|
if (key === 'Escape') { clear(); trigger('cancelSelection'); return; }
|
|
if (/^[0-9]$/.test(key) && (key !== '0' || count)) { count = String(Math.min(Number(count + key), 1000)); update(); return; }
|
|
if (prefix) {
|
|
const chord = prefix + key; clear();
|
|
const semantic = { gd: 'definition', gc: 'callers', gr: 'references' } as const;
|
|
if (chord in semantic) { repeat = 'locations'; update(); void navigation.navigate(semantic[chord as keyof typeof semantic], true); }
|
|
else if (chord === 'gg') { editor.setPosition({ lineNumber: amount, column: 1 }); editor.revealPositionInCenter(editor.getPosition()!); }
|
|
else if (chord === 'ge') trigger('cursorWordEndLeft', amount);
|
|
else if (chord === 'za') trigger('editor.toggleFold');
|
|
else if (chord === 'zc') trigger('editor.fold');
|
|
else if (chord === 'zo') trigger('editor.unfold');
|
|
else if (chord === 'zM') trigger('editor.foldAll');
|
|
else if (chord === 'zR') trigger('editor.unfoldAll');
|
|
else if (chord === 'zz') editor.revealPositionInCenter(editor.getPosition()!);
|
|
return;
|
|
}
|
|
if (key === 'g' || key === 'z') { prefix = key; update(); return; }
|
|
const hadCount = !!count; clear();
|
|
if (key === 'G') {
|
|
editor.setPosition({ lineNumber: hadCount ? Math.min(amount, editor.getModel()!.getLineCount()) : editor.getModel()!.getLineCount(), column: 1 }); editor.revealPositionInCenter(editor.getPosition()!);
|
|
} else if (key === '/') { repeat = 'search'; update(); trigger('actions.find'); }
|
|
else if (key === 'n' || key === 'N') {
|
|
if (repeat === 'locations') void navigation.step(key === 'n' ? 1 : -1, amount);
|
|
else trigger(key === 'n' ? 'editor.action.nextMatchFindAction' : 'editor.action.previousMatchFindAction', amount);
|
|
} else if (key === 'K') void navigation.navigate('hover');
|
|
else {
|
|
const motions: Record<string, string> = { h: 'cursorLeft', j: 'cursorDown', k: 'cursorUp', l: 'cursorRight', w: 'cursorWordStartRight', b: 'cursorWordStartLeft', e: 'cursorWordEndRight', '0': 'cursorHome', '^': 'cursorHome', '$': 'cursorEnd', '%': 'editor.action.jumpToBracket' };
|
|
if (key === '0' || key === '^') {
|
|
const p = editor.getPosition()!;
|
|
editor.setPosition({ lineNumber: p.lineNumber, column: key === '0' ? 1 : editor.getModel()!.getLineFirstNonWhitespaceColumn(p.lineNumber) || 1 });
|
|
} else if (motions[key]) trigger(motions[key], amount);
|
|
// Other printable keys are consumed: i/a/o/d/c/x never edit or enter a mode.
|
|
}
|
|
}, true);
|
|
return { reset() { repeat = 'search'; clear(); } };
|
|
}
|