Modal editing
The textarea below is an ordinary textarea inside an ordinary form: it takes a
caret, it takes typing, and it posts exactly what it always posted. If
/vim.js loads and can drive it, it also answers to Esc,
i, dd and friends, and the statusline and the
reference below appear to say so. Nothing is stashed behind a
<noscript>: the plain textarea is what the server sent, and the
modal furniture is only revealed once the keys are actually wired, so a script
that never runs and a script that breaks halfway both leave the same page.
Modal editing is attached: Esc for normal mode, i to type, dd to delete a line, u to undo, :w to save. Everything else is under Commands.
Modal editing is off: the textarea below is an ordinary textarea, taking a caret and typing and nothing else, with Tab, the arrows and the Ctrl shortcuts left to the browser as they always were. Tick Modal editing underneath it to turn the keys back on. The answer is kept in this browser, not on the server.
Commands
Normal mode unless it says insert. A count before a command repeats it, so 3dw deletes three words and 4j moves four lines down. The statusline echoes whatever you have typed so far, and : turns that echo into a command line for one ex command.
Getting in and out
- i
- Insert before the cursor.
- a
- Insert after the cursor.
- I
- Insert at the first non-blank character of the line.
- A
- Insert at the end of the line.
- o
- Open a line below and insert there.
- O
- Open a line above and insert there.
- Esc Ctrl+[
- Back to normal mode.
Moving
- h j k l
- Left, down, up, right.
- 0
- Start of the line.
- ^
- First non-blank character of the line.
- $
- End of the line. The caret stops on the last character, so $ then x deletes it; an operator takes the exclusive range instead, so d$ leaves the line break alone.
- w b e
- Start of the next word, start of the previous word, end of the word.
- W B E
- The same three, but punctuation counts as part of a word.
- gg G
- Top of the buffer, bottom of the buffer. With a count, go to that line: 3G is line 3.
- Enter Backspace Delete
- These keep their vim meanings, so they are not left to the browser either: Enter goes to the first non-blank of the next line, Backspace is h, Delete is x, and a count repeats any of them. None of the three serves a pending operator, so d then Enter cancels the d rather than deleting a line.
Changing text
- d c y
- Delete, change, yank. Each waits for a motion: dw, c$, yb. c drops into insert mode.
- dd cc yy
- The same over whole lines. Doubling the operator aims it at the line itself.
- x X
- Delete the character under the cursor, or before it.
- D C
- Delete or change from the cursor to the end of the line.
- p P
- Paste after, or before, the cursor. Linewise text lands on its own line. The register is whatever you last yanked, deleted or changed.
- J
- Join the next line onto this one, collapsing its indent.
- u
- Undo, via the browser's own undo stack. An edit
that had to fall back to
setRangeText, becauseinsertTextordeletewas refused, never reached that stack: u then says so on the statusline rather than looking like a dead key. - Tab
- Insert mode only: indent. Shift+Tab removes a tab, or up to four spaces.
Command line
: puts a one-line prompt on the statusline in place of the mode
word, and takes the keyboard until Enter runs what you typed or
Esc throws it away. Backspace edits the line,
Ctrl+U empties it, and a command it does not know answers
with vim's E492 in the echo, where the next keystroke clears it.
- :n
- Go to line n, onto its first non-blank character, exactly where 3G would land.
- :$ :.
- The last line of the buffer, and the line the caret is on.
- :+n :-n
- Relative addresses, so :+2 is two lines down, and a bare :+ is one.
- :w
- Write. Submit the form that is already on
the page, exactly as the Save button submits it: same action, same method, same
.value. Where that text ends up is the server's concern.
It is a prompt, not a shell: one command, no word splitting, and every command reads the rest of the line as its argument. That is what the substitution and :r entries further down were waiting for.
Left to the browser
Tab in normal mode, the arrow keys, Home and End, PageUp and PageDown, anything with Ctrl except [, and anything with Cmd or Alt behave exactly as they always did. An open command line is the one place that differs: it keeps the arrows and Delete for itself, so they cannot move the caret under the prompt, while Tab still moves focus — the way out of the field — and Cmd and Alt shortcuts still belong to the browser. Anything that touches the unnamed register, whether a yank, a delete or a change, also mirrors to the system clipboard, and a native paste fills the register.
Putting it on another form
The page above is the whole recipe: a plain textarea, a marker attribute, a place to print the statusline, and one script tag. Nothing is added to the form itself, so a page that loses the script keeps the form it always had.
<div data-vim>
<form class="form" method="post">
<fieldset>
<legend><label for="note">Note</label></legend>
<textarea id="note" name="note" rows="12"></textarea>
</fieldset>
<button>Save</button>
</form>
<p class="vim-status" data-vim-only hidden>
<span role="status">
<span data-vim-mode>-- NORMAL --</span>
<span data-vim-buffer></span>
</span>
<span data-vim-position aria-hidden="true">1:1</span>
</p>
</div>
Then allow the module on that page and load it once, anywhere in the document:
<% ctx.AllowScripts(); %>
<script type="module" src="/vim.js"></script>
What the module looks for
data-vim- The marker.
autostartattaches every[data-vim] textareaand everytextarea[data-vim]in document order, so it can go on a wrapper or on the textarea itself. data-vim-mode- Writes
-- NORMAL --or-- INSERT --, and nothing at all while a command line is open, so the colon can sit at the left edge of the bar. data-vim-buffer- The keys typed so far in a pending command, so a half-typed d or a count is visible, or the whole command line, colon first, while one is open. It also carries a one-off note, such as what u says when the browser cannot reach the last change or what an unknown command answers; the next keystroke clears it.
data-vim-position- The caret\u0027s line and column.
data-vim-only- Hidden until the keys are actually wired. The statusline carries it, so a script that never runs leaves no empty bar behind.
data-vim-ready- Set on the textarea once it has attached. A textarea already carrying it is left alone, so a second call is harmless.
data-vim-switch- The checkbox that turns modal editing
on and off, with
data-vim-optionon its wrapper: the wrapper stays hidden until the answer is settled, so the box never shows the wrong tick.data-vim-off-onlymarks whatever should be said only while the keys are off. A page without the switch simply starts loaded, as before.
The three statusline parts are found by querySelector inside the
nearest [data-vim] ancestor \u2014 the textarea\u0027s parent element when there
is none \u2014 so the bar may sit above or below the textarea, and needs no class of
its own: only the data-vim-* attributes and the script matter. The
classes in the example come from /vim.css, which this page
loads to look like an editor and a bigger site would not.
Starting it, and attaching later
Importing the file is enough. If the document is still loading it calls
autostart on DOMContentLoaded, otherwise it runs at once.
A textarea that arrives afterwards, from a fetch or a template, takes one line of
your own module:
import { attach } from "/vim.js";
attach(document.querySelector("#note"));
attach returns a small object with mode(), or
null if it declined: no element, no setRangeText on it, or
already attached. Every textarea gets its own instance, so a page can carry several
editors as long as each keeps to its own [data-vim] wrapper and status
bar.
One module per theme
/vim.js is the entry point and nothing else, so it stays small:
attach, autostart, the default export, and the one side
effect that starts the page. The editor behind it is split by what it is about, and
each piece is a page of its own:
/vim-text.js (lines, offsets and words, and no
DOM at all), /vim-state.js (the half-typed
command), /vim-statusline.js,
/vim-register.js (the register and the
clipboard that mirrors it), /vim-buffer.js
(writing into the textarea, and the browser's undo stack),
/vim-motions.js,
/vim-operators.js,
/vim-insert.js,
/vim-ex.js (the command line),
/vim-keys.js (the normal-mode grammar) and
/vim-editor.js (one attached textarea, wiring
the rest together). Dependencies run one way — geometry, motions and operators
are leaves, keys and ex are branches, the editor is the root — and the browser
loads each file as an ordinary module import, so the split still costs no build
step.
Leaving the form a form
Nothing is intercepted and nothing is rewritten. attach reads and
writes .value and the selection and stops there: the Save button posts
what it always posted, Enter in insert mode is Enter, and the
server still renders the first version of the text and still receives the last.
The one thing that submits is :w, and it
submits this same form through requestSubmit — the button's own path
rather than a second one. The keys a textarea needs to stay navigable —
Tab, the arrows, Ctrl and Cmd shortcuts — are
still the browser's in normal mode, so a keyboard user is never trapped in the
field.
One caveat before copying this onto a bigger page: the reveal step at the end of
attach is document-wide, so every [data-vim-only] element
on the page is unhidden by the first textarea that attaches \u2014 and hidden again by the
last one to detach, which is why the test is the flag on the textarea rather than a
counter of our own.
Turning it off again
The Modal editing box under the editor is the whole of it, and
detach is the other half of attach: it forgets the
half-typed command, takes the seven listeners off and clears
data-vim-ready, and touches nothing else. .value, the
selection, the form and the browser\u0027s undo stack are left alone, so un-ticking it
is not an edit \u2014 and because that flag is also the attach gate, ticking it again
puts back exactly what was there. A detached textarea is indistinguishable from one
the script never reached, which is the point: no early-returning key handler
pretending to be off, and nothing a screen reader or a validator could tell apart.
The first answer is a guess, made once at load from the input device:
(pointer: coarse) and (not (any-pointer: fine)) means a phone with no
hardware keyboard anywhere to drive the keys with, so the editor starts unloaded. A
tablet with a trackpad and a laptop with a touchscreen both answer no and start
loaded. The guess is never revisited \u2014 a media query that took the keys away in the
middle of a buffer would be exactly the surprise this is meant to prevent \u2014 and
every answer the visitor gives is kept in localStorage and outranks it
from then on. Three states, then: never answered, on, and off.
Not working yet
Nothing known is broken any more: what follows is simply missing. Roughly easiest and most useful first.
Missing features
- Goal column for j and k easy, high value Remember the column you were aiming for, so stepping down over a short line and back up does not strand the cursor at the end of the long one.
- :%s/pattern/replacement/g
moderate, high value
Replace across the whole buffer, using JavaScript
RegExpfor the match and echoing the substitution count. The command line already hands it its argument; what is still missing is the substitution itself, and making one replace-all a single undo step, which is the fiddly part given the currentexecCommandpath. - :r read another page in moderate, medium value :r /some/page drops that page's text in below the cursor. Fetching a page returns rendered HTML, so this wants a raw-text view from the host — likely the same endpoint the real editor will need. Same-origin only, no credentials.
- Visual mode (v and V) moderate, high value Extend a selection with motions, then run an operator on it, instead of the selection living outside the modal state.
- Text objects moderate, high value iw, aw, i", ip, so ciw changes the word under the cursor no matter where in it you are standing.
- / search, with n and N moderate, high value The biggest gap once a document is longer than the textarea. Also unlocks text objects built on search.
- . repeat moderate, high value Re-run the last change. This is what turns the operator grammar from available into fast.
- Register prefix " moderate, medium value There is one unnamed register, and every yank, delete and change writes to it and so to the system clipboard, which means a dd loses the text you just yanked. "ay then "ap would let a yank survive the next delete, "0 would keep the most recent yank apart from the small deletes, and "+ would make the clipboard an explicit choice rather than an always-on mirror.
- Indent-aware paste, >> and << moderate, low value Shift lines in and out with the same tab semantics as insert mode, rather than hand-editing whitespace.
- Marks and a jump list moderate, low value ma to set, 'a to return, plus Ctrl+O to go back to where you were before the last jump.
- Soft-wrap aware geometry
hard, medium value
Map offsets to visual lines with a hidden mirror element, so j,
k and $ follow the drawn line and not the logical one.
Until then the textarea keeps
wrap="off", which is what makes the current approximations tolerable. - Our own undo and redo
hard, low value
u and Ctrl+R with vim's granularity, separate
from the browser stack that
execCommandfeeds today.