Vim keybindings for source edit mode

VimHandler activates in source (edit) mode only. Two modes:
- Insert: standard typing, Esc enters normal mode
- Normal: vim navigation and editing, i/a/o/O enter insert

Normal mode commands:
  h/j/k/l: cursor movement
  w/b: word forward/back
  0/$: line start/end
  gg/G: document start/end
  i/a/o/O: enter insert mode
  x: delete char
  dd: delete line
  u: undo
  Ctrl+r: redo
This commit is contained in:
gsb 2026-04-29 07:32:54 +00:00
parent 8bef75e59f
commit 3368e719fd
5 changed files with 357 additions and 1 deletions

View File

@ -55,3 +55,13 @@
#ribbit.wysiwyg blockquote.ribbit-editing::before {
content: "> ";
}
#ribbit.vim-normal {
cursor: default;
caret-color: transparent;
border-left: 3px solid #4af;
}
#ribbit.vim-insert {
border-left: 3px solid #4f4;
}

View File

@ -6,6 +6,7 @@ import { HopDown } from './hopdown';
import { defaultTags, defaultBlockTags, defaultInlineTags, inlineTag } from './tags';
import { defaultTheme } from './default-theme';
import { Ribbit, camelCase, decodeHtmlEntities, encodeHtmlEntities } from './ribbit';
import { VimHandler } from './vim';
import { type MacroDef } from './macros';
/**
@ -22,6 +23,7 @@ import { type MacroDef } from './macros';
* editor.view(); // switch to read-only view
*/
export class RibbitEditor extends Ribbit {
private vim!: VimHandler;
run(): void {
this.states = {
@ -30,6 +32,18 @@ export class RibbitEditor extends Ribbit {
WYSIWYG: 'wysiwyg'
};
this.vim = new VimHandler((mode) => {
if (mode === 'normal') {
this.toolbar.disable();
this.element.classList.add('vim-normal');
this.element.classList.remove('vim-insert');
} else {
this.toolbar.enable();
this.element.classList.add('vim-insert');
this.element.classList.remove('vim-normal');
}
});
this.#bindEvents();
this.element.classList.add('loaded');
if (this.autoToolbar) {
@ -204,6 +218,7 @@ export class RibbitEditor extends Ribbit {
wysiwyg(): void {
if (this.getState() === this.states.WYSIWYG) return;
this.vim.detach();
this.element.contentEditable = 'true';
this.element.innerHTML = this.getHTML();
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
@ -223,6 +238,7 @@ export class RibbitEditor extends Ribbit {
if (this.state === this.states.EDIT) return;
this.element.contentEditable = 'true';
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
this.vim.attach(this.element);
this.setState(this.states.EDIT);
}
@ -247,4 +263,5 @@ export { defaultTags, defaultBlockTags, defaultInlineTags };
export { defaultTheme };
export { camelCase, decodeHtmlEntities, encodeHtmlEntities };
export { ToolbarManager } from './toolbar';
export { VimHandler } from './vim';
export type { MacroDef };

248
src/ts/vim.ts Normal file
View File

@ -0,0 +1,248 @@
/*
* vim.ts vim keybinding handler for ribbit source edit mode.
*
* Two modes: normal and insert. Activated in source (edit) mode only.
* Esc enters normal mode, i/a/o/O enter insert mode.
*
* Normal mode commands:
* h/j/k/l cursor movement
* w/b word forward/back
* 0/$ line start/end
* gg/G document start/end
* i insert before cursor
* a insert after cursor
* o new line below, insert
* O new line above, insert
* x delete char under cursor
* dd delete line
* u undo
* Ctrl+r redo
*/
type VimMode = 'normal' | 'insert';
export class VimHandler {
mode: VimMode;
private element: HTMLElement | null;
private listener: ((e: KeyboardEvent) => void) | null;
private pending: string;
private count: string;
private onModeChange: (mode: VimMode) => void;
constructor(onModeChange: (mode: VimMode) => void) {
this.mode = 'insert';
this.element = null;
this.listener = null;
this.pending = '';
this.count = '';
this.onModeChange = onModeChange;
}
attach(element: HTMLElement): void {
this.detach();
this.element = element;
this.pending = '';
this.listener = (e: KeyboardEvent) => this.handleKey(e);
this.element.addEventListener('keydown', this.listener);
this.setMode('insert');
}
detach(): void {
if (this.element && this.listener) {
this.element.removeEventListener('keydown', this.listener);
this.element.classList.remove('vim-normal', 'vim-insert');
}
this.element = null;
this.listener = null;
this.mode = 'insert';
this.pending = '';
}
private setMode(mode: VimMode): void {
this.mode = mode;
this.pending = '';
this.count = '';
this.onModeChange(mode);
}
private handleKey(e: KeyboardEvent): void {
if (this.mode === 'insert') {
if (e.key === 'Escape') {
e.preventDefault();
this.setMode('normal');
}
return;
}
// Normal mode — prevent all default text input
e.preventDefault();
// Undo/redo with Ctrl
if (e.ctrlKey) {
if (e.key === 'r') {
document.execCommand('redo');
}
return;
}
const key = e.key;
// Accumulate count prefix (digits, but not 0 as first char — that's line start)
if (/^[0-9]$/.test(key) && (this.count || key !== '0')) {
this.count += key;
return;
}
const repeat = parseInt(this.count || '1', 10);
this.count = '';
// Two-char commands
if (this.pending) {
const combo = this.pending + key;
this.pending = '';
for (let n = 0; n < repeat; n++) {
this.handlePending(combo);
}
return;
}
switch (key) {
// Mode switching — no repeat
case 'i':
this.setMode('insert');
break;
case 'a':
this.moveCursor('right');
this.setMode('insert');
break;
case 'o':
this.endOfLine();
this.insertNewline();
this.setMode('insert');
break;
case 'O':
this.startOfLine();
this.insertNewline();
this.moveCursor('up');
this.setMode('insert');
break;
// Movement — repeatable
case 'h':
for (let n = 0; n < repeat; n++) this.moveCursor('left');
break;
case 'j':
for (let n = 0; n < repeat; n++) this.moveCursor('down');
break;
case 'k':
for (let n = 0; n < repeat; n++) this.moveCursor('up');
break;
case 'l':
for (let n = 0; n < repeat; n++) this.moveCursor('right');
break;
case 'w':
for (let n = 0; n < repeat; n++) this.wordForward();
break;
case 'b':
for (let n = 0; n < repeat; n++) this.wordBack();
break;
case '0':
this.startOfLine();
break;
case '$':
this.endOfLine();
break;
case 'G':
this.endOfDocument();
break;
// Editing — repeatable
case 'x':
for (let n = 0; n < repeat; n++) this.deleteChar();
break;
case 'u':
for (let n = 0; n < repeat; n++) document.execCommand('undo');
break;
// Pending commands — count preserved for the second key
case 'd':
case 'g':
this.pending = key;
// Restore count so it's available for the pending handler
if (repeat > 1) {
this.count = String(repeat);
}
break;
}
}
private handlePending(combo: string): void {
switch (combo) {
case 'dd':
this.deleteLine();
break;
case 'gg':
this.startOfDocument();
break;
}
}
private moveCursor(direction: 'left' | 'right' | 'up' | 'down'): void {
const sel = window.getSelection();
if (!sel) return;
sel.modify('move', direction === 'left' || direction === 'up' ? 'backward' : 'forward',
direction === 'up' || direction === 'down' ? 'line' : 'character');
}
private wordForward(): void {
window.getSelection()?.modify('move', 'forward', 'word');
}
private wordBack(): void {
window.getSelection()?.modify('move', 'backward', 'word');
}
private startOfLine(): void {
window.getSelection()?.modify('move', 'backward', 'lineboundary');
}
private endOfLine(): void {
window.getSelection()?.modify('move', 'forward', 'lineboundary');
}
private startOfDocument(): void {
const sel = window.getSelection();
if (!sel || !this.element) return;
const range = document.createRange();
range.setStart(this.element, 0);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
}
private endOfDocument(): void {
const sel = window.getSelection();
if (!sel || !this.element) return;
const range = document.createRange();
range.selectNodeContents(this.element);
range.collapse(false);
sel.removeAllRanges();
sel.addRange(range);
}
private deleteChar(): void {
document.execCommand('forwardDelete');
}
private deleteLine(): void {
this.startOfLine();
window.getSelection()?.modify('extend', 'forward', 'lineboundary');
document.execCommand('delete');
// Delete the newline too
document.execCommand('forwardDelete');
}
private insertNewline(): void {
document.execCommand('insertLineBreak');
}
}

View File

@ -22,7 +22,10 @@ export function getWindow(): any {
}
export function ribbit(): any {
return getWindow().ribbit;
const w = getWindow();
const r = w.ribbit;
r.window = w;
return r;
}
export function resetDOM(content = 'test'): void {

78
test/vim.test.ts Normal file
View File

@ -0,0 +1,78 @@
import { ribbit, resetDOM } from './setup';
const r = ribbit();
describe('VimHandler', () => {
beforeEach(() => resetDOM('hello world'));
it('starts in insert mode', () => {
const editor = new r.Editor({});
editor.run();
editor.edit();
expect(editor.element.classList.contains('vim-insert')).toBe(true);
});
it('Esc enters normal mode', () => {
const editor = new r.Editor({});
editor.run();
editor.edit();
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(true);
expect(editor.element.classList.contains('vim-insert')).toBe(false);
});
it('i returns to insert mode', () => {
const editor = new r.Editor({});
editor.run();
editor.edit();
// Enter normal mode
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
// Back to insert
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'i' }));
expect(editor.element.classList.contains('vim-insert')).toBe(true);
expect(editor.element.classList.contains('vim-normal')).toBe(false);
});
it('disables toolbar in normal mode', () => {
const editor = new r.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
editor.edit();
editor.toolbar.enable();
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
const bold = editor.toolbar.buttons.get('bold');
expect(bold?.element?.classList.contains('disabled')).toBe(true);
});
it('re-enables toolbar in insert mode', () => {
const editor = new r.Editor({ autoToolbar: false });
editor.run();
editor.toolbar.render();
editor.edit();
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'i' }));
const bold = editor.toolbar.buttons.get('bold');
expect(bold?.element?.classList.contains('disabled')).toBe(false);
});
it('detaches when leaving edit mode', () => {
const editor = new r.Editor({});
editor.run();
editor.edit();
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(true);
editor.wysiwyg();
// vim classes should be gone after mode switch
expect(editor.element.classList.contains('vim-normal')).toBe(false);
expect(editor.element.classList.contains('vim-insert')).toBe(false);
});
it('only activates in edit mode', () => {
const editor = new r.Editor({});
editor.run();
editor.wysiwyg();
// Esc in wysiwyg should not add vim classes
editor.element.dispatchEvent(new r.window.KeyboardEvent('keydown', { key: 'Escape' }));
expect(editor.element.classList.contains('vim-normal')).toBe(false);
});
});