@davidsouther/vimput 0.0.0-stage → 0.0.0
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 +21 -4
- package/src/index.ts +9 -0
- package/src/vimput.ts +413 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidsouther/vimput",
|
|
3
|
-
"version": "0.0.0
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.0.0",
|
|
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
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