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:
parent
8bef75e59f
commit
3368e719fd
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
248
src/ts/vim.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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
78
test/vim.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user