'use strict'; /** * this is the little modal editor we bolt onto a textarea. it tracks its * own caret + mode, draws a status line underneath, and translates key * presses into vim-ish motions and edits on the textarea value. the pure * text helpers (line_start, word_forward, ...) hang off the class as static * methods since they take (string, position) and return a number with no * instance state */ export class Vim_Editor { /** * this function returns the index of the start of the line that pos sits * on * * @param {string} s * @param {number} pos * @return {number} */ static line_start(s, pos) { const i = s.lastIndexOf('\n', pos - 1); return i === -1 ? 0 : i + 1; } /** * this function returns the index of the end of the line pos sits on, * i.e. the next newline or the end of the string * * @param {string} s * @param {number} pos * @return {number} */ static line_end(s, pos) { const i = s.indexOf('\n', pos); return i === -1 ? s.length : i; } /** * this function returns the index of the last real character on pos's * line (where the block cursor can sit in normal mode) * * @param {string} s * @param {number} pos * @return {number} */ static last_col(s, pos) { const ls = Vim_Editor.line_start(s, pos); const le = Vim_Editor.line_end(s, pos); return le > ls ? le - 1 : ls; } /** * this function returns the index of the first non-blank character on * pos's line (for ^, I, gg, G) * * @param {string} s * @param {number} pos * @return {number} */ static first_nonblank(s, pos) { const ls = Vim_Editor.line_start(s, pos); const le = Vim_Editor.line_end(s, pos); let i = ls; while (i < le && (s[i] === ' ' || s[i] === '\t')) { i++; } return i < le ? i : ls; } /** * this function buckets a character for word motions: 0 = whitespace, * 1 = word char (\w), 2 = punctuation * * @param {string} ch * @return {number} */ static char_class(ch) { if (ch === undefined || ch === ' ' || ch === '\t' || ch === '\n') { return 0; } if (/[A-Za-z0-9_]/.test(ch)) { return 1; } return 2; } /** * this function returns the position of the next word start (like w) * * @param {string} s * @param {number} pos * @return {number} */ static word_forward(s, pos) { const n = s.length; if (pos >= n) { return n; } const c = Vim_Editor.char_class(s[pos]); if (c !== 0) { while (pos < n && Vim_Editor.char_class(s[pos]) === c) { pos++; } } while (pos < n && Vim_Editor.char_class(s[pos]) === 0) { pos++; } return pos; } /** * this function returns the position of the end of the current/next word * (like e) * * @param {string} s * @param {number} pos * @return {number} */ static word_end(s, pos) { const n = s.length; pos++; while (pos < n && Vim_Editor.char_class(s[pos]) === 0) { pos++; } if (pos >= n) { return n - 1; } const c = Vim_Editor.char_class(s[pos]); while (pos + 1 < n && Vim_Editor.char_class(s[pos + 1]) === c) { pos++; } return pos; } /** * this function returns the position of the previous word start (like b) * * @param {string} s * @param {number} pos * @return {number} */ static word_back(s, pos) { pos--; while (pos > 0 && Vim_Editor.char_class(s[pos]) === 0) { pos--; } if (pos <= 0) { return 0; } const c = Vim_Editor.char_class(s[pos]); while (pos - 1 >= 0 && Vim_Editor.char_class(s[pos - 1]) === c) { pos--; } return pos; } constructor(textarea) { this.ta = textarea; this.mode = 'normal'; this.caret = textarea.selectionStart || 0; this.count = ''; this.op = null; this.prefix = null; this.await_char = null; this.anchor = 0; this.reg = { text: '', linewise: false }; this.history = []; this.cmd_active = false; this.cmd = ''; this.status = document.createElement('div'); this.status.className = 'vim-status'; this.ta.insertAdjacentElement('afterend', this.status); this.on_key = this.handle_key.bind(this); this.on_click = this.handle_click.bind(this); this.ta.addEventListener('keydown', this.on_key); this.ta.addEventListener('mouseup', this.on_click); this.ta.classList.add('vim-on'); this.render(); } /** * this function unhooks the editor from its textarea and removes the * status line, putting the box back to a plain textarea * * @return {void} */ detach() { this.ta.removeEventListener('keydown', this.on_key); this.ta.removeEventListener('mouseup', this.on_click); this.ta.classList.remove('vim-on', 'vim-normal', 'vim-insert', 'vim-visual'); if (this.status && this.status.parentNode) { this.status.parentNode.removeChild(this.status); } } text() { return this.ta.value; } /** * this function pushes the current value + caret onto the undo stack * (capped so it doesnt grow forever) * * @return {void} */ snapshot() { this.history.push({ v: this.ta.value, caret: this.caret }); if (this.history.length > 200) { this.history.shift(); } } /** * this function pops the last snapshot and restores it (u) * * @return {void} */ undo() { const prev = this.history.pop(); if (!prev) { return; } this.ta.value = prev.v; this.caret = Math.min(prev.caret, this.ta.value.length); } /** * this function keeps the caret inside the current line in normal mode, * so it never lands past the last character or on the newline * * @return {void} */ clamp_normal() { const s = this.text(); if (this.caret > s.length) { this.caret = s.length; } const ls = Vim_Editor.line_start(s, this.caret); const lc = Vim_Editor.last_col(s, this.caret); if (this.caret > lc) { this.caret = lc; } if (this.caret < ls) { this.caret = ls; } } /** * this function redraws the status line for the current mode and paints * the "cursor": a one-char selection in normal mode (so it reads like a * block cursor) or the full range in visual mode * * @return {void} */ render() { const s = this.text(); this.ta.classList.remove('vim-normal', 'vim-insert', 'vim-visual'); this.ta.classList.add('vim-' + this.mode); if (this.mode === 'insert') { this.status.textContent = '-- INSERT --'; return; } if (this.cmd_active) { this.status.textContent = ':' + this.cmd; } else if (this.mode === 'visual') { this.status.textContent = '-- VISUAL --'; } else { this.status.textContent = 'NORMAL'; } if (this.mode === 'visual') { const lo = Math.min(this.anchor, this.caret); const hi = Math.max(this.anchor, this.caret) + 1; this.ta.setSelectionRange(lo, Math.min(hi, s.length)); } else { const end = this.caret < s.length && s[this.caret] !== '\n' ? this.caret + 1 : this.caret; this.ta.setSelectionRange(this.caret, end); } } /** * this function syncs our caret to wherever the mouse put it (so clicking * around in normal mode behaves) * * @return {void} */ handle_click() { if (this.mode === 'insert') { return; } this.caret = this.ta.selectionStart; this.clamp_normal(); this.render(); } /** * this function inserts text at the current selection in insert mode * (used for the tab key) * * @param {string} text * @return {void} */ insert_text(text) { const ta = this.ta; const start = ta.selectionStart; const end = ta.selectionEnd; ta.value = ta.value.slice(0, start) + text + ta.value.slice(end); ta.setSelectionRange(start + text.length, start + text.length); } /** * this function drops into insert mode at the given caret, snapshotting * first so the whole insert can be undone in one go * * @param {number} caret * @return {void} */ to_insert(caret) { this.snapshot(); this.mode = 'insert'; this.caret = caret; this.ta.setSelectionRange(caret, caret); this.render(); } /** * this function leaves insert mode back to normal, nudging the caret left * one like vim does on escape * * @return {void} */ to_normal() { if (this.mode === 'insert') { this.caret = this.ta.selectionStart; const ls = Vim_Editor.line_start(this.text(), this.caret); if (this.caret > ls) { this.caret--; } } this.mode = 'normal'; this.op = null; this.prefix = null; this.count = ''; this.clamp_normal(); this.render(); } /** * this function resolves a character motion (h l w b e j k) n times and * returns the resulting index without moving the caret * * @param {string} key * @param {number} n * @return {number} */ motion(key, n) { const s = this.text(); let pos = this.caret; for (let i = 0; i < n; i++) { if (key === 'h') { pos = Math.max(Vim_Editor.line_start(s, pos), pos - 1); } else if (key === 'l') { pos = Math.min(Vim_Editor.last_col(s, pos), pos + 1); } else if (key === 'w') { pos = Vim_Editor.word_forward(s, pos); } else if (key === 'b') { pos = Vim_Editor.word_back(s, pos); } else if (key === 'e') { pos = Vim_Editor.word_end(s, pos); } else if (key === 'j' || key === 'k') { const col = pos - Vim_Editor.line_start(s, pos); if (key === 'j') { const nle = Vim_Editor.line_end(s, pos); if (nle >= s.length) { break; } const ns = nle + 1; pos = Math.min(ns + col, Vim_Editor.last_col(s, ns)); } else { const cls = Vim_Editor.line_start(s, pos); if (cls === 0) { break; } const ps = Vim_Editor.line_start(s, cls - 1); pos = Math.min(ps + col, Vim_Editor.last_col(s, ps)); } } } return pos; } /** * this function returns the [from, to) range covering n whole lines from * the caret, used by linewise operators like dd / yy * * @param {number} n * @return {{from: number, to: number}} */ line_range(n) { const s = this.text(); const from = Vim_Editor.line_start(s, this.caret); let to = from; for (let i = 0; i < n; i++) { to = Vim_Editor.line_end(s, to); if (to < s.length) { to++; } } return { from: from, to: to }; } /** * this function applies an operator (d/c/y) over a range. it yanks into * the register, and for d/c it also deletes the text (c then drops into * insert) * * @param {string} op * @param {number} from * @param {number} to * @param {boolean} linewise * @return {void} */ apply_op(op, from, to, linewise) { const s = this.text(); if (from > to) { const t = from; from = to; to = t; } const chunk = s.slice(from, to); if (op === 'y') { this.reg = { text: chunk, linewise: linewise }; this.caret = from; return; } this.snapshot(); this.reg = { text: chunk, linewise: linewise }; this.ta.value = s.slice(0, from) + s.slice(to); this.caret = from; if (op === 'c') { if (linewise) { this.ta.value = this.ta.value.slice(0, from) + '\n' + this.ta.value.slice(from); } this.mode = 'normal'; this.to_insert(from); return; } this.clamp_normal(); } /** * this function pastes the register after (p) or before (P) the caret, * handling linewise vs charwise registers the way vim does * * @param {boolean} after * @return {void} */ paste(after) { const s = this.text(); this.snapshot(); if (this.reg.linewise) { let insert_at; if (after) { insert_at = Vim_Editor.line_end(s, this.caret); this.ta.value = s.slice(0, insert_at) + '\n' + this.reg.text.replace(/\n$/, '') + s.slice(insert_at); this.caret = insert_at + 1; } else { insert_at = Vim_Editor.line_start(s, this.caret); this.ta.value = s.slice(0, insert_at) + this.reg.text.replace(/\n$/, '') + '\n' + s.slice(insert_at); this.caret = insert_at; } } else { const at = after ? Math.min(this.caret + 1, s.length) : this.caret; this.ta.value = s.slice(0, at) + this.reg.text + s.slice(at); this.caret = at + this.reg.text.length - 1; } this.clamp_normal(); } /** * this function is the keydown handler. in insert mode it only intercepts * escape and tab and lets everything else type through; otherwise it * routes the key into dispatch (or the command line if one is open) * * @param {KeyboardEvent} e * @return {void} */ handle_key(e) { if (this.cmd_active) { this.handle_cmdline(e); return; } if (this.mode === 'insert') { if (e.key === 'Escape') { e.preventDefault(); this.to_normal(); } else if (e.key === 'Tab') { e.preventDefault(); this.insert_text(' '); } return; } if (e.ctrlKey || e.metaKey || e.altKey) { return; } if (e.key === 'Shift' || e.key === 'CapsLock') { return; } e.preventDefault(); this.dispatch(e.key); } /** * this function is the normal/visual mode command interpreter. it handles * pending operators, counts, the g prefix, r, motions, edits, and the * mode switches * * @param {string} key * @return {void} */ dispatch(key) { const s = this.text(); if (this.await_char) { if (this.await_char === 'r' && key.length === 1) { this.snapshot(); this.ta.value = s.slice(0, this.caret) + key + s.slice(this.caret + 1); } this.await_char = null; this.render(); return; } if (this.prefix === 'g') { this.prefix = null; if (key === 'g') { this.caret = Vim_Editor.first_nonblank(s, 0); } this.render(); return; } if (this.mode === 'visual' && 'dxyc'.indexOf(key) !== -1) { this.handle_visual(key); return; } if (/[1-9]/.test(key) || (key === '0' && this.count !== '')) { this.count += key; return; } const n = this.count === '' ? 1 : parseInt(this.count, 10); this.count = ''; if (key === 'g') { this.prefix = 'g'; return; } if (this.op) { this.handle_operator_motion(key, n); return; } if ('hjklwbe'.indexOf(key) !== -1) { this.caret = this.motion(key, n); this.clamp_normal_for(key); this.render(); return; } switch (key) { case '0': this.caret = Vim_Editor.line_start(s, this.caret); break; case '^': this.caret = Vim_Editor.first_nonblank(s, this.caret); break; case '$': this.caret = Vim_Editor.last_col(s, this.caret); break; case 'G': this.caret = Vim_Editor.first_nonblank(s, s.length); break; case 'i': this.to_insert(this.caret); return; case 'a': this.to_insert(Math.min(this.caret + 1, s.length)); return; case 'I': this.to_insert(Vim_Editor.first_nonblank(s, this.caret)); return; case 'A': this.to_insert(Vim_Editor.line_end(s, this.caret)); return; case 'o': this.open_line(true); return; case 'O': this.open_line(false); return; case 'x': this.delete_chars(n); break; case 'D': this.apply_op('d', this.caret, Vim_Editor.line_end(s, this.caret), false); break; case 'C': this.apply_op('c', this.caret, Vim_Editor.line_end(s, this.caret), false); return; case 'r': this.await_char = 'r'; return; case 'd': case 'c': case 'y': this.op = key; this.op_count = n; return; case 'p': this.paste(true); break; case 'P': this.paste(false); break; case 'u': this.undo(); break; case 'v': this.mode = 'visual'; this.anchor = this.caret; break; case ':': this.cmd_active = true; this.cmd = ''; this.render(); return; case 'Escape': if (this.mode === 'visual') { this.mode = 'normal'; } break; default: if (this.mode === 'visual') { this.handle_visual(key); return; } this.render(); return; } if (this.mode === 'visual' && 'hjklwbe0^$G'.indexOf(key) !== -1) { this.render(); return; } this.clamp_normal(); this.render(); } clamp_normal_for(key) { if (this.mode === 'visual') { const s = this.text(); if (this.caret > s.length) { this.caret = s.length; } return; } this.clamp_normal(); } /** * this function handles d/x/y/c while in visual mode: it operates over * the current selection then drops back to normal mode * * @param {string} key * @return {void} */ handle_visual(key) { const s = this.text(); if (key === 'd' || key === 'x' || key === 'y' || key === 'c') { const lo = Math.min(this.anchor, this.caret); const hi = Math.max(this.anchor, this.caret) + 1; const op = key === 'x' ? 'd' : key; this.mode = 'normal'; this.apply_op(op, lo, Math.min(hi, s.length), false); if (op !== 'c') { this.render(); } return; } this.render(); } /** * this function completes an operator that was waiting on a motion (dd, * dw, cw, d$, yy, ...) and applies it over the resulting range * * @param {string} key * @param {number} n * @return {void} */ handle_operator_motion(key, n) { const s = this.text(); const op = this.op; this.op = null; const nn = (this.op_count || 1) * n; if (key === op || (op === 'd' && key === 'd') || (op === 'c' && key === 'c') || (op === 'y' && key === 'y')) { const lr = this.line_range(nn); this.apply_op(op, lr.from, lr.to, true); this.render(); return; } const from = this.caret; let to = this.caret; if ('wbe'.indexOf(key) !== -1) { to = this.motion(key, nn); if (key === 'e') { to += 1; } } else if (key === 'l' || key === 'h') { to = this.motion(key, nn); } else if (key === '$') { to = Vim_Editor.line_end(s, this.caret); } else if (key === '0') { to = Vim_Editor.line_start(s, this.caret); } else { this.render(); return; } this.apply_op(op, from, to, false); this.render(); } /** * this function deletes n characters from the caret (x), stopping at the * end of the line * * @param {number} n * @return {void} */ delete_chars(n) { const s = this.text(); const le = Vim_Editor.line_end(s, this.caret); const to = Math.min(this.caret + n, le); if (to <= this.caret) { return; } this.apply_op('d', this.caret, to, false); } /** * this function opens a new line below (o) or above (O) and drops into * insert mode on it * * @param {boolean} below * @return {void} */ open_line(below) { const s = this.text(); this.snapshot(); let at; if (below) { at = Vim_Editor.line_end(s, this.caret); this.ta.value = s.slice(0, at) + '\n' + s.slice(at); this.mode = 'normal'; this.to_insert(at + 1); } else { at = Vim_Editor.line_start(s, this.caret); this.ta.value = s.slice(0, at) + '\n' + s.slice(at); this.mode = 'normal'; this.to_insert(at); } } /** * this function feeds keystrokes into the ":" command line and runs it on * enter (escape cancels, backspace edits) * * @param {KeyboardEvent} e * @return {void} */ handle_cmdline(e) { e.preventDefault(); if (e.key === 'Escape') { this.cmd_active = false; this.cmd = ''; this.render(); return; } if (e.key === 'Enter') { this.cmd_active = false; this.run_command(this.cmd); this.cmd = ''; this.render(); return; } if (e.key === 'Backspace') { this.cmd = this.cmd.slice(0, -1); this.render(); return; } if (e.key.length === 1) { this.cmd += e.key; this.render(); } } /** * this function runs an ex command. only the "write" variants are wired * up, and they submit the post form via its primary button * * @param {string} cmd * @return {void} */ run_command(cmd) { cmd = cmd.trim(); if (cmd === 'w' || cmd === 'wq' || cmd === 'x' || cmd === 'wq!') { const form = this.ta.form; if (!form) { return; } const primary = form.querySelector('button[value="create"], button[value="post"]'); if (primary && form.requestSubmit) { form.requestSubmit(primary); } else { form.submit(); } } } } /** * this function wires up the page: it reads the saved preference, syncs the * "vim" checkboxes, and attaches/detaches editors on the post boxes when the * toggle changes. its progressive enhancement, so with js off the boxes are * just plain textareas * * @return {void} */ function vim_init() { let pref = false; try { pref = localStorage.getItem('forum_vim') === '1'; } catch (e) {} const toggles = Array.prototype.slice.call(document.querySelectorAll('.vim-toggle input')); let editors = []; function targets() { return Array.prototype.slice.call(document.querySelectorAll('form .vim-area')); } function enable() { editors = targets().map((t) => new Vim_Editor(t)); } function disable() { editors.forEach((ed) => ed.detach()); editors = []; } toggles.forEach((cb) => { cb.checked = pref; cb.addEventListener('change', () => { toggles.forEach((t) => { t.checked = cb.checked; }); try { localStorage.setItem('forum_vim', cb.checked ? '1' : '0'); } catch (e) {} disable(); if (cb.checked) { enable(); } }); }); if (pref) { enable(); } } if (typeof document !== 'undefined' && document.querySelectorAll) { vim_init(); }