@davidsouther/vimput 0.0.0-stage → 2026.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,23 @@
1
1
  {
2
2
  "name": "@davidsouther/vimput",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2026.11.1",
4
+ "type": "module",
5
+ "description": "Vim-over-textarea utility.",
6
+ "license": "MIT",
7
+ "repository": "git+https://github.com/DavidSouther/agathon.git",
8
+ "sideEffects": false,
9
+ "files": [
10
+ "src",
11
+ "!src/**/*_test.ts"
12
+ ],
13
+ "exports": {
14
+ ".": "./src/vimput.ts",
15
+ "./*": "./src/*.ts"
16
+ },
17
+ "scripts": {
18
+ "test": "node --test \"src/**/*_test.ts\""
19
+ },
20
+ "dependencies": {
21
+ "@davidsouther/agathon": "*"
22
+ }
23
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { NgModule } from "@angular/core";
2
+
3
+ import { Vimput } from "./vimput";
4
+
5
+ @NgModule({
6
+ declarations: [Vimput],
7
+ exports: [Vimput],
8
+ })
9
+ export class VimputModule {}
package/src/vimput.ts ADDED
@@ -0,0 +1,413 @@
1
+ import { checkExhaustive } from "@davidsouther/agathon/assert";
2
+
3
+ export const VimputMode = {
4
+ NORMAL: 0,
5
+ EDIT: 1,
6
+ } as const;
7
+ export type VimputMode = (typeof VimputMode)[keyof typeof VimputMode];
8
+
9
+ export const Stride = {
10
+ LINE: 0,
11
+ } as const;
12
+ export type Stride = (typeof Stride)[keyof typeof Stride];
13
+
14
+ export const Direction = {
15
+ BACKWARD: 0,
16
+ FORWARD: 1,
17
+ } as const;
18
+ export type Direction = (typeof Direction)[keyof typeof Direction];
19
+
20
+ export const VimputCommandType = {
21
+ MOVE: 0,
22
+ EDIT: 1,
23
+ UPDATE: 2,
24
+ UNDO: 3,
25
+ REDO: 4,
26
+ } as const;
27
+ export type VimputCommandType = (typeof VimputCommandType)[keyof typeof VimputCommandType];
28
+
29
+ export interface VimputMove {
30
+ type: typeof VimputCommandType.MOVE;
31
+ op(position: VimputPosition): void;
32
+ }
33
+
34
+ export interface VimputEdit {
35
+ type: typeof VimputCommandType.EDIT;
36
+ op(position: VimputPosition): string;
37
+ value?: string;
38
+ }
39
+
40
+ export interface VimputUpdate {
41
+ type: typeof VimputCommandType.UPDATE;
42
+ value: string;
43
+ index?: number;
44
+ }
45
+
46
+ export interface VimputHistory {
47
+ type: typeof VimputCommandType.UNDO | typeof VimputCommandType.REDO;
48
+ }
49
+
50
+ type VimputCommand = VimputMove | VimputEdit | VimputUpdate | VimputHistory;
51
+
52
+ const Command = {
53
+ move(op: (position: VimputPosition) => void): VimputCommand {
54
+ return { type: VimputCommandType.MOVE, op };
55
+ },
56
+
57
+ edit(op: (position: VimputPosition) => string): VimputCommand {
58
+ return { type: VimputCommandType.EDIT, op };
59
+ },
60
+
61
+ update(value: string, index?: number): VimputCommand {
62
+ return { type: VimputCommandType.UPDATE, value, index };
63
+ },
64
+
65
+ history(type: typeof VimputCommandType.UNDO | typeof VimputCommandType.REDO): VimputCommand {
66
+ return { type };
67
+ },
68
+ };
69
+
70
+ const UNDO = VimputCommandType.UNDO;
71
+ const REDO = VimputCommandType.REDO;
72
+
73
+ export interface VimputState {
74
+ row: number;
75
+ col: number;
76
+ value: string;
77
+ }
78
+
79
+ /** Class to handle vim row/column position model over a TextArea value. */
80
+ export class VimputPosition {
81
+ row = 0;
82
+ col = 0;
83
+
84
+ // Number of characters preceding this row
85
+ rows: number[] = [0];
86
+
87
+ value = "";
88
+
89
+ get length(): number {
90
+ return this.value.length;
91
+ }
92
+
93
+ get index(): number {
94
+ return this.textIndex(this.row, this.col);
95
+ }
96
+
97
+ constructor() {
98
+ this.update();
99
+ }
100
+
101
+ update(): void {
102
+ this.rows.splice(1, this.rows.length);
103
+
104
+ let row = 1,
105
+ index = 0,
106
+ next: number;
107
+ while ((next = this.value.indexOf("\n", index) + 1) > 0) {
108
+ this.rows[row++] = index = next;
109
+ }
110
+ }
111
+
112
+ /** Return the index in the value at the beginning of this row. */
113
+ lineIndex(row: number): number {
114
+ // Asking for a row off the end gets the total length of the string.
115
+ return this.rows[row] ?? this.length;
116
+ }
117
+
118
+ textIndex(row: number, col: number): number {
119
+ // Convert row/col to text index
120
+ let index = this.lineIndex(row);
121
+
122
+ index += col;
123
+ let next = (this.rows[row + 1] ?? 0) - 1;
124
+ next = next > -1 ? next : this.length;
125
+ // Col is after the line, so use the end of the line
126
+ return index > next ? next : index;
127
+ }
128
+
129
+ moveToIndex(index: number): void {
130
+ if (index > this.length) index = this.length;
131
+ let row = this.rows.findIndex((r) => r >= index);
132
+ if (row === -1) row = this.rows.length;
133
+ row = Math.max(0, row - 1);
134
+ let col = index - (this.rows[row] ?? this.length);
135
+ col = Math.max(0, col);
136
+ this.row = row;
137
+ this.col = col;
138
+ }
139
+
140
+ moveTo(row: number, col: number): void {
141
+ this.row = clamp(row, this.rows.length);
142
+ // Columns can keep their old position when moving up and down
143
+ this.col = Math.max(col, 0);
144
+ }
145
+
146
+ move(row: -1 | 0 | 1, col: -1 | 0 | 1): void {
147
+ const toRow = clamp(this.row + row, this.rows.length);
148
+ const cols = (this.rows[toRow + 1] ?? this.length) - this.rows[toRow] - 1;
149
+ // If moving cols, don't move off the current column to the right. If moving
150
+ // left, jump to the last column if starting off the right.
151
+ let toCol: number;
152
+ if (col === 0) {
153
+ toCol = this.col;
154
+ } else if (col === 1 && this.col > cols) {
155
+ toCol = this.col;
156
+ } else if (col === -1 && this.col > cols) {
157
+ toCol = cols - 1;
158
+ } else {
159
+ toCol = this.col + col;
160
+ }
161
+ this.moveTo(toRow, toCol);
162
+ }
163
+
164
+ jump(distance: Stride, direction: Direction): void {
165
+ switch (distance) {
166
+ case Stride.LINE:
167
+ switch (direction) {
168
+ case Direction.FORWARD:
169
+ this.col = this.rows[this.row + 1] - 1;
170
+ break;
171
+ case Direction.BACKWARD:
172
+ this.col = 0;
173
+ break;
174
+ default:
175
+ checkExhaustive(direction);
176
+ }
177
+ break;
178
+ default:
179
+ checkExhaustive(distance);
180
+ }
181
+ }
182
+ }
183
+
184
+ export class VimputManager {
185
+ position = new VimputPosition();
186
+
187
+ undo: VimputState[] = [];
188
+ redo: VimputState[] = [];
189
+
190
+ handleCommand(command: VimputCommand): void {
191
+ switch (command.type) {
192
+ case VimputCommandType.MOVE:
193
+ command.op(this.position);
194
+ break;
195
+ case VimputCommandType.EDIT:
196
+ this.position.value = command.op(this.position);
197
+ this.position.update();
198
+ break;
199
+ case VimputCommandType.UPDATE:
200
+ this.position.value = command.value;
201
+ this.position.update();
202
+ if (this.position.index) {
203
+ this.position.moveToIndex(this.position.index);
204
+ }
205
+ this.pushState();
206
+ break;
207
+ case VimputCommandType.UNDO:
208
+ if (this.undo.length > 0) {
209
+ // Don't remove the last state entry
210
+ const state = this.undo.length > 1 ? this.undo.shift()! : this.undo[0];
211
+ this.position.value = state.value;
212
+ this.redo.push(state);
213
+ }
214
+ break;
215
+ case VimputCommandType.REDO:
216
+ if (this.redo.length > 0) {
217
+ this.position.value = this.redo.pop()!.value;
218
+ }
219
+ break;
220
+ default:
221
+ checkExhaustive(command);
222
+ }
223
+ }
224
+
225
+ pushState(): void {
226
+ const first = this.undo.length === 0;
227
+ const { value, row, col } = this.position;
228
+ const top = this.undo[0];
229
+ if (first || value !== top.value || row !== top.row || col !== top.col) {
230
+ this.undo.unshift({ value, row, col });
231
+ }
232
+ }
233
+ }
234
+
235
+ /** Directive to add Vim bindings to an HTMLTextArea */
236
+ export const useVimput = (el: HTMLTextAreaElement) => {
237
+ const [mode, setMode] = useState(VimputMode.NORMAL);
238
+
239
+ const listeners = useMemo(() => {
240
+ const manager = new VimputManager();
241
+ const focus = (): void => {
242
+ updateIndex();
243
+ };
244
+
245
+ const click = (): void => {
246
+ updateIndex();
247
+ };
248
+
249
+ const keyDown = (e: KeyboardEvent): void => {
250
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
251
+ if (mode === VimputMode.EDIT) {
252
+ if (e.key === "Escape") {
253
+ setMode(VimputMode.NORMAL);
254
+ update();
255
+ }
256
+ if (e.key === "Tab") {
257
+ insert("\t");
258
+ }
259
+ }
260
+
261
+ if (mode === VimputMode.NORMAL) {
262
+ if (e.key === "Tab") return;
263
+ e.preventDefault();
264
+ normalMode(e);
265
+ }
266
+ };
267
+
268
+ const update = (): void => {
269
+ doUpdate(el.value);
270
+ };
271
+
272
+ const updateCaret = (): void => {
273
+ el.value = manager.position.value;
274
+ const index = manager.position.index;
275
+ el.setSelectionRange(index, index);
276
+ };
277
+
278
+ const updateIndex = (): void => {
279
+ doUpdate(el.value, el.selectionEnd);
280
+ };
281
+
282
+ const doUpdate = (value: string, index?: number): void => {
283
+ manager.handleCommand(Command.update(value, index));
284
+ updateCaret();
285
+ };
286
+
287
+ const command = (cmd: VimputCommand): void => {
288
+ manager.handleCommand(cmd);
289
+ updateCaret();
290
+ };
291
+
292
+ const moveTo = (row: number, col: number): void => {
293
+ command(Command.move((position) => position.moveTo(row, col)));
294
+ };
295
+
296
+ const move = (row: -1 | 0 | 1, col: -1 | 0 | 1): void => {
297
+ command(Command.move((position) => position.move(row, col)));
298
+ };
299
+
300
+ const jump = (distance: Stride, direction: Direction): void => {
301
+ command(Command.move((position) => position.jump(distance, direction)));
302
+ };
303
+
304
+ const insert = (s: string): void => {
305
+ command(Command.edit(({ value, index }) => splice(value, index, s)));
306
+ };
307
+
308
+ const doDelete = (n: number): void => {
309
+ command(Command.edit(({ value, index }) => splice(value, index, n)));
310
+ };
311
+
312
+ const normalMode = (e: KeyboardEvent): void => {
313
+ (operations[e.key] ?? (() => undefined))();
314
+ };
315
+
316
+ const operations = {
317
+ // TODO need to figure out how to do a block cursor. Until then, these do the
318
+ // same thing. That will probably require replacing the entire contenteditable
319
+ ["a"](): void {
320
+ setMode(VimputMode.EDIT);
321
+ },
322
+ ["i"](): void {
323
+ this.mode = VimputMode.EDIT;
324
+ },
325
+
326
+ ["h"](): void {
327
+ move(0, -1);
328
+ },
329
+ ["j"](): void {
330
+ move(1, 0);
331
+ },
332
+ ["k"](): void {
333
+ move(-1, 0);
334
+ },
335
+ ["l"](): void {
336
+ move(0, 1);
337
+ },
338
+
339
+ ["^"](): void {
340
+ jump(Stride.LINE, Direction.BACKWARD);
341
+ },
342
+ ["$"](): void {
343
+ jump(Stride.LINE, Direction.FORWARD);
344
+ },
345
+
346
+ ["x"](): void {
347
+ if (this.value[this.manager.position.index] !== "\n") this.delete(1);
348
+ },
349
+
350
+ ["u"](): void {
351
+ command(Command.history(UNDO));
352
+ },
353
+ ["U"](): void {
354
+ command(Command.history(REDO));
355
+ },
356
+
357
+ // TODO
358
+ // eEwWbB
359
+ // IAoO
360
+ // r
361
+ // %
362
+ // vV
363
+ // yp
364
+ // status line
365
+ // /find nN
366
+ // %s/find/replace
367
+ // '<,'>s/find/replace
368
+ // {n}command
369
+ };
370
+
371
+ return {
372
+ focus,
373
+ click,
374
+ keyDown,
375
+ };
376
+ });
377
+
378
+ useEffect(() => {
379
+ el.addEventListener("keydown", listeners.keyDown);
380
+ el.addEventListener("focus", listeners.focus);
381
+ el.addEventListener("click", listeners.click);
382
+ return () => {
383
+ el.removeEventListener("keydown", listeners.keyDown);
384
+ el.removeEventListener("focus", listeners.focus);
385
+ el.removeEventListener("click", listeners.click);
386
+ };
387
+ }, [el]);
388
+ };
389
+
390
+ function clamp(x: number, max: number, min = 0) {
391
+ return Math.min(max, Math.max(min, x));
392
+ }
393
+
394
+ /** splice a string using the same API as Array::splice */
395
+ export function splice(
396
+ str: string,
397
+ pos: number,
398
+ len?: number | string,
399
+ ...insert: string[]
400
+ ): string {
401
+ if (typeof len === "string") {
402
+ insert.unshift(len);
403
+ len = 0;
404
+ } else if (len === undefined) {
405
+ len = 0;
406
+ }
407
+
408
+ const start = str.slice(0, pos);
409
+ const middle = insert.join("");
410
+ const end = str.slice(pos + len, str.length);
411
+ const value = start + middle + end;
412
+ return value;
413
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.