diff --git a/src/static/ribbit-core.css b/src/static/ribbit-core.css index 9b7a11a..ba93025 100644 --- a/src/static/ribbit-core.css +++ b/src/static/ribbit-core.css @@ -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; +} diff --git a/src/ts/ribbit-editor.ts b/src/ts/ribbit-editor.ts index 9c7c2da..dfaa3f8 100644 --- a/src/ts/ribbit-editor.ts +++ b/src/ts/ribbit-editor.ts @@ -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 }; diff --git a/src/ts/vim.ts b/src/ts/vim.ts new file mode 100644 index 0000000..eba405f --- /dev/null +++ b/src/ts/vim.ts @@ -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'); + } +} diff --git a/test/setup.ts b/test/setup.ts index 4acf588..eac3024 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -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 { diff --git a/test/vim.test.ts b/test/vim.test.ts new file mode 100644 index 0000000..90755e9 --- /dev/null +++ b/test/vim.test.ts @@ -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); + }); +});