ep_vim 0.1.0 → 0.6.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.
@@ -1,63 +1,58 @@
1
- 'use strict';
1
+ "use strict";
2
+
3
+ const {
4
+ clampLine,
5
+ clampChar,
6
+ getLineText,
7
+ firstNonBlank,
8
+ wordForward,
9
+ wordBackward,
10
+ wordEnd,
11
+ charSearchPos,
12
+ motionRange,
13
+ charMotionRange,
14
+ textWordRange,
15
+ textQuoteRange,
16
+ textBracketRange,
17
+ getVisualSelection,
18
+ paragraphForward,
19
+ paragraphBackward,
20
+ getTextInRange,
21
+ } = require("./vim-core");
2
22
 
3
23
  // --- State variables ---
4
24
 
5
- let vimEnabled = localStorage.getItem('ep_vimEnabled') === 'true';
25
+ let vimEnabled = localStorage.getItem("ep_vimEnabled") === "true";
6
26
  let insertMode = false;
7
27
  let visualMode = null;
8
28
  let visualAnchor = null;
9
29
  let visualCursor = null;
10
30
  let pendingKey = null;
11
31
  let pendingCount = null;
12
- let countBuffer = '';
32
+ let countBuffer = "";
13
33
  let register = null;
14
34
  let marks = {};
15
35
  let editorDoc = null;
16
36
  let currentRep = null;
37
+ let desiredColumn = null;
38
+ let lastCharSearch = null;
17
39
 
18
- // --- Utility helpers ---
40
+ const QUOTE_CHARS = new Set(['"', "'"]);
41
+ const BRACKET_CHARS = new Set(["(", ")", "{", "}", "[", "]"]);
19
42
 
20
- const isWordChar = (ch) => /\w/.test(ch);
21
- const isWhitespace = (ch) => /\s/.test(ch);
22
-
23
- const clampLine = (line, rep) => Math.max(0, Math.min(line, rep.lines.length() - 1));
24
-
25
- const clampChar = (char, lineText) => Math.max(0, Math.min(char, lineText.length - 1));
26
-
27
- const getLineText = (rep, line) => rep.lines.atIndex(line).text;
28
-
29
- const firstNonBlank = (lineText) => {
30
- let i = 0;
31
- while (i < lineText.length && isWhitespace(lineText[i])) i++;
32
- return i;
43
+ const textObjectRange = (key, lineText, char, type) => {
44
+ if (key === "w") return textWordRange(lineText, char);
45
+ if (QUOTE_CHARS.has(key)) return textQuoteRange(lineText, char, key, type);
46
+ if (BRACKET_CHARS.has(key))
47
+ return textBracketRange(lineText, char, key, type);
33
48
  };
34
49
 
35
- const findCharForward = (lineText, startChar, targetChar, count) => {
36
- let found = 0;
37
- for (let i = startChar + 1; i < lineText.length; i++) {
38
- if (lineText[i] === targetChar) {
39
- found++;
40
- if (found === count) return i;
41
- }
42
- }
43
- return -1;
44
- };
45
-
46
- const findCharBackward = (lineText, startChar, targetChar, count) => {
47
- let found = 0;
48
- for (let i = startChar - 1; i >= 0; i--) {
49
- if (lineText[i] === targetChar) {
50
- found++;
51
- if (found === count) return i;
52
- }
53
- }
54
- return -1;
55
- };
50
+ // --- Count helpers ---
56
51
 
57
52
  const consumeCount = () => {
58
- if (countBuffer !== '') {
53
+ if (countBuffer !== "") {
59
54
  pendingCount = parseInt(countBuffer, 10);
60
- countBuffer = '';
55
+ countBuffer = "";
61
56
  } else if (pendingKey === null) {
62
57
  pendingCount = null;
63
58
  }
@@ -65,17 +60,17 @@ const consumeCount = () => {
65
60
 
66
61
  const getCount = () => pendingCount || 1;
67
62
 
63
+ // --- Side-effectful helpers ---
64
+
68
65
  const setRegister = (value) => {
69
66
  register = value;
70
- const text = Array.isArray(value) ? value.join('\n') + '\n' : value;
67
+ const text = Array.isArray(value) ? value.join("\n") + "\n" : value;
71
68
  navigator.clipboard.writeText(text).catch(() => {});
72
69
  };
73
70
 
74
- // --- Etherpad API wrappers ---
75
-
76
71
  const moveCursor = (editorInfo, line, char) => {
77
72
  const pos = [line, char];
78
- editorInfo.ace_inCallStackIfNecessary('vim-move', () => {
73
+ editorInfo.ace_inCallStackIfNecessary("vim-move", () => {
79
74
  editorInfo.ace_performSelectionChange(pos, pos, false);
80
75
  editorInfo.ace_updateBrowserSelectionFromRep();
81
76
  });
@@ -83,16 +78,16 @@ const moveCursor = (editorInfo, line, char) => {
83
78
 
84
79
  const clearEmptyLineCursor = () => {
85
80
  if (!editorDoc) return;
86
- const old = editorDoc.querySelector('.vim-empty-line-cursor');
87
- if (old) old.classList.remove('vim-empty-line-cursor');
81
+ const old = editorDoc.querySelector(".vim-empty-line-cursor");
82
+ if (old) old.classList.remove("vim-empty-line-cursor");
88
83
  };
89
84
 
90
85
  const moveBlockCursor = (editorInfo, line, char) => {
91
86
  clearEmptyLineCursor();
92
- const lineText = currentRep ? getLineText(currentRep, line) : '';
87
+ const lineText = currentRep ? getLineText(currentRep, line) : "";
93
88
  if (lineText.length === 0 && editorDoc) {
94
- const lineDiv = editorDoc.body.querySelectorAll('div')[line];
95
- if (lineDiv) lineDiv.classList.add('vim-empty-line-cursor');
89
+ const lineDiv = editorDoc.body.querySelectorAll("div")[line];
90
+ if (lineDiv) lineDiv.classList.add("vim-empty-line-cursor");
96
91
  selectRange(editorInfo, [line, 0], [line, 0]);
97
92
  } else {
98
93
  selectRange(editorInfo, [line, char], [line, char + 1]);
@@ -100,20 +95,20 @@ const moveBlockCursor = (editorInfo, line, char) => {
100
95
  };
101
96
 
102
97
  const selectRange = (editorInfo, start, end) => {
103
- editorInfo.ace_inCallStackIfNecessary('vim-select', () => {
98
+ editorInfo.ace_inCallStackIfNecessary("vim-select", () => {
104
99
  editorInfo.ace_performSelectionChange(start, end, false);
105
100
  editorInfo.ace_updateBrowserSelectionFromRep();
106
101
  });
107
102
  };
108
103
 
109
104
  const replaceRange = (editorInfo, start, end, text) => {
110
- editorInfo.ace_inCallStackIfNecessary('vim-edit', () => {
105
+ editorInfo.ace_inCallStackIfNecessary("vim-edit", () => {
111
106
  editorInfo.ace_performDocumentReplaceRange(start, end, text);
112
107
  });
113
108
  };
114
109
 
115
110
  const undo = (editorInfo) => {
116
- editorInfo.ace_doUndoRedo('undo');
111
+ editorInfo.ace_doUndoRedo("undo");
117
112
  };
118
113
 
119
114
  // --- Mode management ---
@@ -122,91 +117,30 @@ const setInsertMode = (value) => {
122
117
  insertMode = value;
123
118
  if (value) clearEmptyLineCursor();
124
119
  if (editorDoc) {
125
- editorDoc.body.classList.toggle('vim-insert-mode', value);
120
+ editorDoc.body.classList.toggle("vim-insert-mode", value);
126
121
  }
127
122
  };
128
123
 
129
124
  const setVisualMode = (value) => {
130
125
  visualMode = value;
131
126
  if (editorDoc) {
132
- editorDoc.body.classList.toggle('vim-visual-line-mode', value === 'line');
133
- editorDoc.body.classList.toggle('vim-visual-char-mode', value === 'char');
127
+ editorDoc.body.classList.toggle("vim-visual-line-mode", value === "line");
128
+ editorDoc.body.classList.toggle("vim-visual-char-mode", value === "char");
134
129
  }
135
130
  };
136
131
 
137
132
  // --- Visual mode helpers ---
138
133
 
139
- const getVisualSelection = (rep) => {
140
- if (visualMode === 'line') {
141
- const topLine = Math.min(visualAnchor[0], visualCursor[0]);
142
- const bottomLine = Math.max(visualAnchor[0], visualCursor[0]);
143
- const lineCount = rep.lines.length();
144
- const start = [topLine, 0];
145
- const end = bottomLine + 1 < lineCount
146
- ? [bottomLine + 1, 0]
147
- : [bottomLine, getLineText(rep, bottomLine).length];
148
- return [start, end];
149
- }
150
- if (visualAnchor[0] < visualCursor[0] ||
151
- (visualAnchor[0] === visualCursor[0] && visualAnchor[1] <= visualCursor[1])) {
152
- return [visualAnchor, visualCursor];
153
- }
154
- return [visualCursor, visualAnchor];
155
- };
156
-
157
- const getTextInRange = (rep, start, end) => {
158
- if (start[0] === end[0]) {
159
- return getLineText(rep, start[0]).slice(start[1], end[1]);
160
- }
161
- const parts = [];
162
- parts.push(getLineText(rep, start[0]).slice(start[1]));
163
- for (let i = start[0] + 1; i < end[0]; i++) {
164
- parts.push(getLineText(rep, i));
165
- }
166
- parts.push(getLineText(rep, end[0]).slice(0, end[1]));
167
- return parts.join('\n');
168
- };
169
-
170
134
  const updateVisualSelection = (editorInfo, rep) => {
171
- const [start, end] = getVisualSelection(rep);
135
+ const [start, end] = getVisualSelection(
136
+ visualMode,
137
+ visualAnchor,
138
+ visualCursor,
139
+ rep,
140
+ );
172
141
  selectRange(editorInfo, start, end);
173
142
  };
174
143
 
175
- // --- Word motion helpers ---
176
-
177
- const wordForward = (lineText, startChar) => {
178
- let pos = startChar;
179
- if (pos < lineText.length && isWordChar(lineText[pos])) {
180
- while (pos < lineText.length && isWordChar(lineText[pos])) pos++;
181
- } else if (pos < lineText.length && !isWhitespace(lineText[pos])) {
182
- while (pos < lineText.length && !isWordChar(lineText[pos]) && !isWhitespace(lineText[pos])) pos++;
183
- }
184
- while (pos < lineText.length && isWhitespace(lineText[pos])) pos++;
185
- return pos;
186
- };
187
-
188
- const wordBackward = (lineText, startChar) => {
189
- let pos = startChar - 1;
190
- while (pos >= 0 && isWhitespace(lineText[pos])) pos--;
191
- if (pos >= 0 && isWordChar(lineText[pos])) {
192
- while (pos > 0 && isWordChar(lineText[pos - 1])) pos--;
193
- } else {
194
- while (pos > 0 && !isWordChar(lineText[pos - 1]) && !isWhitespace(lineText[pos - 1])) pos--;
195
- }
196
- return Math.max(0, pos);
197
- };
198
-
199
- const wordEnd = (lineText, startChar) => {
200
- let pos = startChar + 1;
201
- while (pos < lineText.length && isWhitespace(lineText[pos])) pos++;
202
- if (pos < lineText.length && isWordChar(lineText[pos])) {
203
- while (pos + 1 < lineText.length && isWordChar(lineText[pos + 1])) pos++;
204
- } else {
205
- while (pos + 1 < lineText.length && !isWordChar(lineText[pos + 1]) && !isWhitespace(lineText[pos + 1])) pos++;
206
- }
207
- return pos;
208
- };
209
-
210
144
  // --- Visual mode key handler ---
211
145
 
212
146
  const handleVisualKey = (rep, editorInfo, key) => {
@@ -214,11 +148,28 @@ const handleVisualKey = (rep, editorInfo, key) => {
214
148
  const curChar = visualCursor[1];
215
149
  const lineText = getLineText(rep, curLine);
216
150
 
217
- if (key >= '1' && key <= '9') {
151
+ if (key === "i" || key === "a") {
152
+ pendingKey = key;
153
+ return true;
154
+ }
155
+
156
+ if (pendingKey === "i" || pendingKey === "a") {
157
+ const range = textObjectRange(key, lineText, curChar, pendingKey);
158
+ pendingKey = null;
159
+ if (range) {
160
+ visualAnchor = [curLine, range.start];
161
+ visualCursor = [curLine, range.end];
162
+ setVisualMode("char");
163
+ updateVisualSelection(editorInfo, rep);
164
+ }
165
+ return true;
166
+ }
167
+
168
+ if (key >= "1" && key <= "9") {
218
169
  countBuffer += key;
219
170
  return true;
220
171
  }
221
- if (key === '0' && countBuffer !== '') {
172
+ if (key === "0" && countBuffer !== "") {
222
173
  countBuffer += key;
223
174
  return true;
224
175
  }
@@ -226,33 +177,30 @@ const handleVisualKey = (rep, editorInfo, key) => {
226
177
  consumeCount();
227
178
  const count = getCount();
228
179
 
229
- if (pendingKey === 'f' || pendingKey === 'F' || pendingKey === 't' || pendingKey === 'T') {
180
+ if (
181
+ pendingKey === "f" ||
182
+ pendingKey === "F" ||
183
+ pendingKey === "t" ||
184
+ pendingKey === "T"
185
+ ) {
230
186
  const direction = pendingKey;
231
187
  pendingKey = null;
232
- let pos = -1;
233
- if (direction === 'f') {
234
- pos = findCharForward(lineText, curChar, key, count);
235
- } else if (direction === 'F') {
236
- pos = findCharBackward(lineText, curChar, key, count);
237
- } else if (direction === 't') {
238
- pos = findCharForward(lineText, curChar, key, count);
239
- if (pos !== -1) pos = pos - 1;
240
- } else if (direction === 'T') {
241
- pos = findCharBackward(lineText, curChar, key, count);
242
- if (pos !== -1) pos = pos + 1;
243
- }
188
+ lastCharSearch = { direction, target: key };
189
+ const pos = charSearchPos(direction, lineText, curChar, key, count);
244
190
  if (pos !== -1) {
191
+ desiredColumn = null;
245
192
  visualCursor = [curLine, pos];
246
193
  updateVisualSelection(editorInfo, rep);
247
194
  }
248
195
  return true;
249
196
  }
250
197
 
251
- if (pendingKey === "'" || pendingKey === '`') {
198
+ if (pendingKey === "'" || pendingKey === "`") {
252
199
  const jumpType = pendingKey;
253
200
  pendingKey = null;
254
- if (key >= 'a' && key <= 'z' && marks[key]) {
201
+ if (key >= "a" && key <= "z" && marks[key]) {
255
202
  const [markLine, markChar] = marks[key];
203
+ desiredColumn = null;
256
204
  if (jumpType === "'") {
257
205
  const targetLineText = getLineText(rep, markLine);
258
206
  visualCursor = [markLine, firstNonBlank(targetLineText)];
@@ -264,49 +212,65 @@ const handleVisualKey = (rep, editorInfo, key) => {
264
212
  return true;
265
213
  }
266
214
 
267
- if (key === 'h') {
215
+ if (key === "h") {
216
+ desiredColumn = null;
268
217
  visualCursor = [curLine, Math.max(0, curChar - count)];
269
218
  updateVisualSelection(editorInfo, rep);
270
219
  return true;
271
220
  }
272
221
 
273
- if (key === 'l') {
222
+ if (key === "l") {
223
+ desiredColumn = null;
274
224
  visualCursor = [curLine, clampChar(curChar + count, lineText)];
275
225
  updateVisualSelection(editorInfo, rep);
276
226
  return true;
277
227
  }
278
228
 
279
- if (key === 'j') {
280
- visualCursor = [clampLine(curLine + count, rep), curChar];
229
+ if (key === "j") {
230
+ if (desiredColumn === null) {
231
+ desiredColumn = curChar;
232
+ }
233
+ const newLine = clampLine(curLine + count, rep);
234
+ const newLineText = getLineText(rep, newLine);
235
+ visualCursor = [newLine, clampChar(desiredColumn, newLineText)];
281
236
  updateVisualSelection(editorInfo, rep);
282
237
  return true;
283
238
  }
284
239
 
285
- if (key === 'k') {
286
- visualCursor = [clampLine(curLine - count, rep), curChar];
240
+ if (key === "k") {
241
+ if (desiredColumn === null) {
242
+ desiredColumn = curChar;
243
+ }
244
+ const newLine = clampLine(curLine - count, rep);
245
+ const newLineText = getLineText(rep, newLine);
246
+ visualCursor = [newLine, clampChar(desiredColumn, newLineText)];
287
247
  updateVisualSelection(editorInfo, rep);
288
248
  return true;
289
249
  }
290
250
 
291
- if (key === '0') {
251
+ if (key === "0") {
252
+ desiredColumn = null;
292
253
  visualCursor = [curLine, 0];
293
254
  updateVisualSelection(editorInfo, rep);
294
255
  return true;
295
256
  }
296
257
 
297
- if (key === '$') {
258
+ if (key === "$") {
259
+ desiredColumn = null;
298
260
  visualCursor = [curLine, clampChar(lineText.length - 1, lineText)];
299
261
  updateVisualSelection(editorInfo, rep);
300
262
  return true;
301
263
  }
302
264
 
303
- if (key === '^') {
265
+ if (key === "^") {
266
+ desiredColumn = null;
304
267
  visualCursor = [curLine, firstNonBlank(lineText)];
305
268
  updateVisualSelection(editorInfo, rep);
306
269
  return true;
307
270
  }
308
271
 
309
- if (key === 'w') {
272
+ if (key === "w") {
273
+ desiredColumn = null;
310
274
  let pos = curChar;
311
275
  for (let i = 0; i < count; i++) pos = wordForward(lineText, pos);
312
276
  visualCursor = [curLine, clampChar(pos, lineText)];
@@ -314,7 +278,8 @@ const handleVisualKey = (rep, editorInfo, key) => {
314
278
  return true;
315
279
  }
316
280
 
317
- if (key === 'b') {
281
+ if (key === "b") {
282
+ desiredColumn = null;
318
283
  let pos = curChar;
319
284
  for (let i = 0; i < count; i++) pos = wordBackward(lineText, pos);
320
285
  visualCursor = [curLine, pos];
@@ -322,7 +287,8 @@ const handleVisualKey = (rep, editorInfo, key) => {
322
287
  return true;
323
288
  }
324
289
 
325
- if (key === 'e') {
290
+ if (key === "e") {
291
+ desiredColumn = null;
326
292
  let pos = curChar;
327
293
  for (let i = 0; i < count; i++) pos = wordEnd(lineText, pos);
328
294
  visualCursor = [curLine, clampChar(pos, lineText)];
@@ -330,8 +296,63 @@ const handleVisualKey = (rep, editorInfo, key) => {
330
296
  return true;
331
297
  }
332
298
 
333
- if (key === 'G') {
299
+ if (key === ";") {
300
+ if (lastCharSearch) {
301
+ const pos = charSearchPos(
302
+ lastCharSearch.direction,
303
+ lineText,
304
+ curChar,
305
+ lastCharSearch.target,
306
+ count,
307
+ );
308
+ if (pos !== -1) {
309
+ desiredColumn = null;
310
+ visualCursor = [curLine, pos];
311
+ updateVisualSelection(editorInfo, rep);
312
+ }
313
+ }
314
+ return true;
315
+ }
316
+
317
+ if (key === ",") {
318
+ if (lastCharSearch) {
319
+ const opposite = { f: "F", F: "f", t: "T", T: "t" };
320
+ const reverseDir = opposite[lastCharSearch.direction];
321
+ const pos = charSearchPos(
322
+ reverseDir,
323
+ lineText,
324
+ curChar,
325
+ lastCharSearch.target,
326
+ count,
327
+ );
328
+ if (pos !== -1) {
329
+ desiredColumn = null;
330
+ visualCursor = [curLine, pos];
331
+ updateVisualSelection(editorInfo, rep);
332
+ }
333
+ }
334
+ return true;
335
+ }
336
+
337
+ if (key === "}") {
338
+ desiredColumn = null;
339
+ const target = paragraphForward(rep, curLine, count);
340
+ visualCursor = [target, 0];
341
+ updateVisualSelection(editorInfo, rep);
342
+ return true;
343
+ }
344
+
345
+ if (key === "{") {
346
+ desiredColumn = null;
347
+ const target = paragraphBackward(rep, curLine, count);
348
+ visualCursor = [target, 0];
349
+ updateVisualSelection(editorInfo, rep);
350
+ return true;
351
+ }
352
+
353
+ if (key === "G") {
334
354
  pendingKey = null;
355
+ desiredColumn = null;
335
356
  if (pendingCount !== null) {
336
357
  visualCursor = [clampLine(pendingCount - 1, rep), curChar];
337
358
  } else {
@@ -341,32 +362,62 @@ const handleVisualKey = (rep, editorInfo, key) => {
341
362
  return true;
342
363
  }
343
364
 
344
- if (key === 'g') {
345
- if (pendingKey === 'g') {
365
+ if (key === "g") {
366
+ if (pendingKey === "g") {
346
367
  pendingKey = null;
368
+ desiredColumn = null;
347
369
  visualCursor = [0, curChar];
348
370
  updateVisualSelection(editorInfo, rep);
349
371
  } else {
350
- pendingKey = 'g';
372
+ pendingKey = "g";
351
373
  }
352
374
  return true;
353
375
  }
354
376
 
355
- if (key === 'f' || key === 'F' || key === 't' || key === 'T') {
377
+ if (key === "f" || key === "F" || key === "t" || key === "T") {
356
378
  pendingKey = key;
357
379
  return true;
358
380
  }
359
381
 
360
- if (key === "'" || key === '`') {
382
+ if (key === "'" || key === "`") {
361
383
  pendingKey = key;
362
384
  return true;
363
385
  }
364
386
 
365
- if (key === 'y') {
366
- const [start] = getVisualSelection(rep);
387
+ if (key === "~") {
388
+ const [start, end] = getVisualSelection(
389
+ visualMode,
390
+ visualAnchor,
391
+ visualCursor,
392
+ rep,
393
+ );
394
+ const text = getTextInRange(rep, start, end);
395
+ let toggled = "";
396
+ for (let i = 0; i < text.length; i++) {
397
+ const ch = text[i];
398
+ toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
399
+ }
400
+ replaceRange(editorInfo, start, end, toggled);
401
+ setVisualMode(null);
402
+ moveBlockCursor(editorInfo, start[0], start[1]);
403
+ return true;
404
+ }
367
405
 
368
- if (visualMode === 'char') {
369
- const [, end] = getVisualSelection(rep);
406
+ if (key === "y") {
407
+ const [start] = getVisualSelection(
408
+ visualMode,
409
+ visualAnchor,
410
+ visualCursor,
411
+ rep,
412
+ );
413
+
414
+ if (visualMode === "char") {
415
+ const [, end] = getVisualSelection(
416
+ visualMode,
417
+ visualAnchor,
418
+ visualCursor,
419
+ rep,
420
+ );
370
421
  setRegister(getTextInRange(rep, start, end));
371
422
  setVisualMode(null);
372
423
  moveBlockCursor(editorInfo, start[0], start[1]);
@@ -385,13 +436,18 @@ const handleVisualKey = (rep, editorInfo, key) => {
385
436
  return true;
386
437
  }
387
438
 
388
- if (key === 'd' || key === 'c') {
389
- const enterInsert = key === 'c';
390
- const [start, end] = getVisualSelection(rep);
439
+ if (key === "d" || key === "c") {
440
+ const enterInsert = key === "c";
441
+ const [start, end] = getVisualSelection(
442
+ visualMode,
443
+ visualAnchor,
444
+ visualCursor,
445
+ rep,
446
+ );
391
447
 
392
- if (visualMode === 'char') {
448
+ if (visualMode === "char") {
393
449
  setRegister(getTextInRange(rep, start, end));
394
- replaceRange(editorInfo, start, end, '');
450
+ replaceRange(editorInfo, start, end, "");
395
451
  if (enterInsert) {
396
452
  moveCursor(editorInfo, start[0], start[1]);
397
453
  setVisualMode(null);
@@ -415,7 +471,7 @@ const handleVisualKey = (rep, editorInfo, key) => {
415
471
  if (enterInsert) {
416
472
  for (let i = topLine; i <= bottomLine; i++) {
417
473
  const text = getLineText(rep, i);
418
- replaceRange(editorInfo, [topLine, 0], [topLine, text.length], '');
474
+ replaceRange(editorInfo, [topLine, 0], [topLine, text.length], "");
419
475
  }
420
476
  moveCursor(editorInfo, topLine, 0);
421
477
  setVisualMode(null);
@@ -425,13 +481,23 @@ const handleVisualKey = (rep, editorInfo, key) => {
425
481
 
426
482
  if (bottomLine === totalLines - 1 && topLine > 0) {
427
483
  const prevLineLen = getLineText(rep, topLine - 1).length;
428
- replaceRange(editorInfo, [topLine - 1, prevLineLen], [bottomLine, getLineText(rep, bottomLine).length], '');
484
+ replaceRange(
485
+ editorInfo,
486
+ [topLine - 1, prevLineLen],
487
+ [bottomLine, getLineText(rep, bottomLine).length],
488
+ "",
489
+ );
429
490
  moveBlockCursor(editorInfo, topLine - 1, 0);
430
491
  } else if (bottomLine < totalLines - 1) {
431
- replaceRange(editorInfo, [topLine, 0], [bottomLine + 1, 0], '');
492
+ replaceRange(editorInfo, [topLine, 0], [bottomLine + 1, 0], "");
432
493
  moveBlockCursor(editorInfo, topLine, 0);
433
494
  } else {
434
- replaceRange(editorInfo, [0, 0], [bottomLine, getLineText(rep, bottomLine).length], '');
495
+ replaceRange(
496
+ editorInfo,
497
+ [0, 0],
498
+ [bottomLine, getLineText(rep, bottomLine).length],
499
+ "",
500
+ );
435
501
  moveBlockCursor(editorInfo, 0, 0);
436
502
  }
437
503
 
@@ -450,11 +516,11 @@ const handleNormalKey = (rep, editorInfo, key) => {
450
516
  const lineCount = rep.lines.length();
451
517
  const lineText = getLineText(rep, line);
452
518
 
453
- if (key >= '1' && key <= '9') {
519
+ if (key >= "1" && key <= "9") {
454
520
  countBuffer += key;
455
521
  return true;
456
522
  }
457
- if (key === '0' && countBuffer !== '') {
523
+ if (key === "0" && countBuffer !== "") {
458
524
  countBuffer += key;
459
525
  return true;
460
526
  }
@@ -462,7 +528,7 @@ const handleNormalKey = (rep, editorInfo, key) => {
462
528
  consumeCount();
463
529
  const count = getCount();
464
530
 
465
- if (pendingKey === 'r') {
531
+ if (pendingKey === "r") {
466
532
  pendingKey = null;
467
533
  if (lineText.length > 0) {
468
534
  replaceRange(editorInfo, [line, char], [line, char + 1], key);
@@ -471,64 +537,61 @@ const handleNormalKey = (rep, editorInfo, key) => {
471
537
  return true;
472
538
  }
473
539
 
474
- if (pendingKey === 'f' || pendingKey === 'F' || pendingKey === 't' || pendingKey === 'T') {
540
+ if (
541
+ pendingKey === "f" ||
542
+ pendingKey === "F" ||
543
+ pendingKey === "t" ||
544
+ pendingKey === "T"
545
+ ) {
475
546
  const direction = pendingKey;
476
547
  pendingKey = null;
477
- let pos = -1;
478
- if (direction === 'f') {
479
- pos = findCharForward(lineText, char, key, count);
480
- } else if (direction === 'F') {
481
- pos = findCharBackward(lineText, char, key, count);
482
- } else if (direction === 't') {
483
- pos = findCharForward(lineText, char, key, count);
484
- if (pos !== -1) pos = pos - 1;
485
- } else if (direction === 'T') {
486
- pos = findCharBackward(lineText, char, key, count);
487
- if (pos !== -1) pos = pos + 1;
548
+ lastCharSearch = { direction, target: key };
549
+ const pos = charSearchPos(direction, lineText, char, key, count);
550
+ if (pos !== -1) {
551
+ desiredColumn = null;
552
+ moveBlockCursor(editorInfo, line, pos);
488
553
  }
489
- if (pos !== -1) moveBlockCursor(editorInfo, line, pos);
490
554
  return true;
491
555
  }
492
556
 
493
- if (pendingKey === 'df' || pendingKey === 'dF' || pendingKey === 'dt' || pendingKey === 'dT') {
557
+ if (
558
+ pendingKey === "df" ||
559
+ pendingKey === "dF" ||
560
+ pendingKey === "dt" ||
561
+ pendingKey === "dT"
562
+ ) {
494
563
  const motion = pendingKey[1];
495
564
  pendingKey = null;
496
- let pos = -1;
497
- if (motion === 'f' || motion === 't') {
498
- pos = findCharForward(lineText, char, key, count);
499
- } else {
500
- pos = findCharBackward(lineText, char, key, count);
501
- }
565
+ const searchDir = motion === "f" || motion === "t" ? motion : motion;
566
+ const pos = charSearchPos(searchDir, lineText, char, key, count);
502
567
  if (pos !== -1) {
503
- let delStart = char;
504
- let delEnd = char;
505
- if (motion === 'f') {
506
- delStart = char;
507
- delEnd = pos + 1;
508
- } else if (motion === 't') {
509
- delStart = char;
510
- delEnd = pos;
511
- } else if (motion === 'F') {
512
- delStart = pos;
513
- delEnd = char + 1;
514
- } else if (motion === 'T') {
515
- delStart = pos + 1;
516
- delEnd = char + 1;
517
- }
518
- if (delEnd > delStart) {
519
- setRegister(lineText.slice(delStart, delEnd));
520
- replaceRange(editorInfo, [line, delStart], [line, delEnd], '');
568
+ const range = charMotionRange(motion, char, pos);
569
+ if (range) {
570
+ setRegister(lineText.slice(range.start, range.end));
571
+ replaceRange(editorInfo, [line, range.start], [line, range.end], "");
521
572
  const newLineText = getLineText(rep, line);
522
- moveBlockCursor(editorInfo, line, clampChar(delStart, newLineText));
573
+ moveBlockCursor(editorInfo, line, clampChar(range.start, newLineText));
523
574
  }
524
575
  }
525
576
  return true;
526
577
  }
527
578
 
528
- if (pendingKey === 'd') {
579
+ if (pendingKey === "di" || pendingKey === "da") {
580
+ const range = textObjectRange(key, lineText, char, pendingKey[1]);
529
581
  pendingKey = null;
582
+ if (range) {
583
+ setRegister(lineText.slice(range.start, range.end));
584
+ replaceRange(editorInfo, [line, range.start], [line, range.end], "");
585
+ const newLineText = getLineText(rep, line);
586
+ moveBlockCursor(editorInfo, line, clampChar(range.start, newLineText));
587
+ }
588
+ return true;
589
+ }
530
590
 
531
- if (key === 'd') {
591
+ if (pendingKey === "d") {
592
+ pendingKey = null;
593
+
594
+ if (key === "d") {
532
595
  const deleteCount = Math.min(count, lineCount - line);
533
596
  const lastDeleteLine = line + deleteCount - 1;
534
597
  const deletedLines = [];
@@ -538,201 +601,144 @@ const handleNormalKey = (rep, editorInfo, key) => {
538
601
  setRegister(deletedLines);
539
602
  if (lastDeleteLine === lineCount - 1 && line > 0) {
540
603
  const prevLineText = getLineText(rep, line - 1);
541
- replaceRange(editorInfo, [line - 1, prevLineText.length], [lastDeleteLine, getLineText(rep, lastDeleteLine).length], '');
604
+ replaceRange(
605
+ editorInfo,
606
+ [line - 1, prevLineText.length],
607
+ [lastDeleteLine, getLineText(rep, lastDeleteLine).length],
608
+ "",
609
+ );
542
610
  moveBlockCursor(editorInfo, line - 1, clampChar(char, prevLineText));
543
611
  } else if (lineCount > deleteCount) {
544
- replaceRange(editorInfo, [line, 0], [lastDeleteLine + 1, 0], '');
612
+ replaceRange(editorInfo, [line, 0], [lastDeleteLine + 1, 0], "");
545
613
  const newLineText = getLineText(rep, line);
546
614
  moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
547
615
  } else {
548
- replaceRange(editorInfo, [0, 0], [lastDeleteLine, getLineText(rep, lastDeleteLine).length], '');
616
+ replaceRange(
617
+ editorInfo,
618
+ [0, 0],
619
+ [lastDeleteLine, getLineText(rep, lastDeleteLine).length],
620
+ "",
621
+ );
549
622
  moveBlockCursor(editorInfo, 0, 0);
550
623
  }
551
624
  return true;
552
625
  }
553
626
 
554
- if (key === 'f' || key === 'F' || key === 't' || key === 'T') {
555
- pendingKey = 'd' + key;
627
+ if (key === "i") {
628
+ pendingKey = "di";
556
629
  return true;
557
630
  }
558
631
 
559
- let delStart = -1;
560
- let delEnd = -1;
561
-
562
- if (key === 'w') {
563
- let pos = char;
564
- for (let i = 0; i < count; i++) pos = wordForward(lineText, pos);
565
- delStart = char;
566
- delEnd = Math.min(pos, lineText.length);
567
- } else if (key === 'e') {
568
- let pos = char;
569
- for (let i = 0; i < count; i++) pos = wordEnd(lineText, pos);
570
- delStart = char;
571
- delEnd = Math.min(pos + 1, lineText.length);
572
- } else if (key === 'b') {
573
- let pos = char;
574
- for (let i = 0; i < count; i++) pos = wordBackward(lineText, pos);
575
- delStart = pos;
576
- delEnd = char;
577
- } else if (key === '$') {
578
- delStart = char;
579
- delEnd = lineText.length;
580
- } else if (key === '0') {
581
- delStart = 0;
582
- delEnd = char;
583
- } else if (key === '^') {
584
- const fnb = firstNonBlank(lineText);
585
- delStart = Math.min(char, fnb);
586
- delEnd = Math.max(char, fnb);
587
- } else if (key === 'h') {
588
- delStart = Math.max(0, char - count);
589
- delEnd = char;
590
- } else if (key === 'l') {
591
- delStart = char;
592
- delEnd = Math.min(char + count, lineText.length);
632
+ if (key === "f" || key === "F" || key === "t" || key === "T") {
633
+ pendingKey = "d" + key;
634
+ return true;
593
635
  }
594
636
 
595
- if (delEnd > delStart && delStart !== -1) {
596
- setRegister(lineText.slice(delStart, delEnd));
597
- replaceRange(editorInfo, [line, delStart], [line, delEnd], '');
637
+ const range = motionRange(key, char, lineText, count);
638
+ if (range && range.end > range.start) {
639
+ setRegister(lineText.slice(range.start, range.end));
640
+ replaceRange(editorInfo, [line, range.start], [line, range.end], "");
598
641
  const newLineText = getLineText(rep, line);
599
- moveBlockCursor(editorInfo, line, clampChar(delStart, newLineText));
642
+ moveBlockCursor(editorInfo, line, clampChar(range.start, newLineText));
600
643
  }
601
644
  return true;
602
645
  }
603
646
 
604
- if (pendingKey === 'yf' || pendingKey === 'yF' || pendingKey === 'yt' || pendingKey === 'yT') {
647
+ if (
648
+ pendingKey === "yf" ||
649
+ pendingKey === "yF" ||
650
+ pendingKey === "yt" ||
651
+ pendingKey === "yT"
652
+ ) {
605
653
  const motion = pendingKey[1];
606
654
  pendingKey = null;
607
- let pos = -1;
608
- if (motion === 'f' || motion === 't') {
609
- pos = findCharForward(lineText, char, key, count);
610
- } else {
611
- pos = findCharBackward(lineText, char, key, count);
612
- }
655
+ const pos = charSearchPos(motion, lineText, char, key, count);
613
656
  if (pos !== -1) {
614
- let yankStart = char;
615
- let yankEnd = char;
616
- if (motion === 'f') {
617
- yankStart = char;
618
- yankEnd = pos + 1;
619
- } else if (motion === 't') {
620
- yankStart = char;
621
- yankEnd = pos;
622
- } else if (motion === 'F') {
623
- yankStart = pos;
624
- yankEnd = char + 1;
625
- } else if (motion === 'T') {
626
- yankStart = pos + 1;
627
- yankEnd = char + 1;
628
- }
629
- if (yankEnd > yankStart) {
630
- setRegister(lineText.slice(yankStart, yankEnd));
657
+ const range = charMotionRange(motion, char, pos);
658
+ if (range) {
659
+ setRegister(lineText.slice(range.start, range.end));
631
660
  }
632
661
  }
633
662
  return true;
634
663
  }
635
664
 
636
- if (pendingKey === 'cf' || pendingKey === 'cF' || pendingKey === 'ct' || pendingKey === 'cT') {
665
+ if (
666
+ pendingKey === "cf" ||
667
+ pendingKey === "cF" ||
668
+ pendingKey === "ct" ||
669
+ pendingKey === "cT"
670
+ ) {
637
671
  const motion = pendingKey[1];
638
672
  pendingKey = null;
639
- let pos = -1;
640
- if (motion === 'f' || motion === 't') {
641
- pos = findCharForward(lineText, char, key, count);
642
- } else {
643
- pos = findCharBackward(lineText, char, key, count);
644
- }
673
+ const pos = charSearchPos(motion, lineText, char, key, count);
645
674
  if (pos !== -1) {
646
- let delStart = char;
647
- let delEnd = char;
648
- if (motion === 'f') {
649
- delStart = char;
650
- delEnd = pos + 1;
651
- } else if (motion === 't') {
652
- delStart = char;
653
- delEnd = pos;
654
- } else if (motion === 'F') {
655
- delStart = pos;
656
- delEnd = char + 1;
657
- } else if (motion === 'T') {
658
- delStart = pos + 1;
659
- delEnd = char + 1;
660
- }
661
- if (delEnd > delStart) {
662
- setRegister(lineText.slice(delStart, delEnd));
663
- replaceRange(editorInfo, [line, delStart], [line, delEnd], '');
664
- moveCursor(editorInfo, line, delStart);
675
+ const range = charMotionRange(motion, char, pos);
676
+ if (range) {
677
+ setRegister(lineText.slice(range.start, range.end));
678
+ replaceRange(editorInfo, [line, range.start], [line, range.end], "");
679
+ moveCursor(editorInfo, line, range.start);
665
680
  setInsertMode(true);
666
681
  }
667
682
  }
668
683
  return true;
669
684
  }
670
685
 
671
- if (pendingKey === 'c') {
686
+ if (pendingKey === "ci" || pendingKey === "ca") {
687
+ const range = textObjectRange(key, lineText, char, pendingKey[1]);
672
688
  pendingKey = null;
689
+ if (range) {
690
+ setRegister(lineText.slice(range.start, range.end));
691
+ replaceRange(editorInfo, [line, range.start], [line, range.end], "");
692
+ moveCursor(editorInfo, line, range.start);
693
+ setInsertMode(true);
694
+ }
695
+ return true;
696
+ }
673
697
 
674
- if (key === 'c') {
698
+ if (pendingKey === "c") {
699
+ pendingKey = null;
700
+
701
+ if (key === "c") {
675
702
  setRegister(lineText);
676
- replaceRange(editorInfo, [line, 0], [line, lineText.length], '');
703
+ replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
677
704
  moveCursor(editorInfo, line, 0);
678
705
  setInsertMode(true);
679
706
  return true;
680
707
  }
681
708
 
682
- if (key === 'f' || key === 'F' || key === 't' || key === 'T') {
683
- pendingKey = 'c' + key;
709
+ if (key === "i") {
710
+ pendingKey = "ci";
684
711
  return true;
685
712
  }
686
713
 
687
- let delStart = -1;
688
- let delEnd = -1;
689
-
690
- if (key === 'w') {
691
- let pos = char;
692
- for (let i = 0; i < count; i++) pos = wordForward(lineText, pos);
693
- delStart = char;
694
- delEnd = Math.min(pos, lineText.length);
695
- } else if (key === 'e') {
696
- let pos = char;
697
- for (let i = 0; i < count; i++) pos = wordEnd(lineText, pos);
698
- delStart = char;
699
- delEnd = Math.min(pos + 1, lineText.length);
700
- } else if (key === 'b') {
701
- let pos = char;
702
- for (let i = 0; i < count; i++) pos = wordBackward(lineText, pos);
703
- delStart = pos;
704
- delEnd = char;
705
- } else if (key === '$') {
706
- delStart = char;
707
- delEnd = lineText.length;
708
- } else if (key === '0') {
709
- delStart = 0;
710
- delEnd = char;
711
- } else if (key === '^') {
712
- const fnb = firstNonBlank(lineText);
713
- delStart = Math.min(char, fnb);
714
- delEnd = Math.max(char, fnb);
715
- } else if (key === 'h') {
716
- delStart = Math.max(0, char - count);
717
- delEnd = char;
718
- } else if (key === 'l') {
719
- delStart = char;
720
- delEnd = Math.min(char + count, lineText.length);
714
+ if (key === "f" || key === "F" || key === "t" || key === "T") {
715
+ pendingKey = "c" + key;
716
+ return true;
721
717
  }
722
718
 
723
- if (delEnd > delStart && delStart !== -1) {
724
- setRegister(lineText.slice(delStart, delEnd));
725
- replaceRange(editorInfo, [line, delStart], [line, delEnd], '');
726
- moveCursor(editorInfo, line, delStart);
719
+ const range = motionRange(key, char, lineText, count);
720
+ if (range && range.end > range.start) {
721
+ setRegister(lineText.slice(range.start, range.end));
722
+ replaceRange(editorInfo, [line, range.start], [line, range.end], "");
723
+ moveCursor(editorInfo, line, range.start);
727
724
  setInsertMode(true);
728
725
  }
729
726
  return true;
730
727
  }
731
728
 
732
- if (pendingKey === 'y') {
729
+ if (pendingKey === "yi") {
730
+ const range = textObjectRange(key, lineText, char, pendingKey[1]);
731
+ pendingKey = null;
732
+ if (range) {
733
+ setRegister(lineText.slice(range.start, range.end));
734
+ }
735
+ return true;
736
+ }
737
+
738
+ if (pendingKey === "y") {
733
739
  pendingKey = null;
734
740
 
735
- if (key === 'y') {
741
+ if (key === "y") {
736
742
  const yankCount = Math.min(count, lineCount - line);
737
743
  const lastYankLine = line + yankCount - 1;
738
744
  const yankedLines = [];
@@ -743,66 +749,37 @@ const handleNormalKey = (rep, editorInfo, key) => {
743
749
  return true;
744
750
  }
745
751
 
746
- if (key === 'f' || key === 'F' || key === 't' || key === 'T') {
747
- pendingKey = 'y' + key;
752
+ if (key === "i") {
753
+ pendingKey = "yi";
748
754
  return true;
749
755
  }
750
756
 
751
- let yankStart = -1;
752
- let yankEnd = -1;
753
-
754
- if (key === 'w') {
755
- let pos = char;
756
- for (let i = 0; i < count; i++) pos = wordForward(lineText, pos);
757
- yankStart = char;
758
- yankEnd = Math.min(pos, lineText.length);
759
- } else if (key === 'e') {
760
- let pos = char;
761
- for (let i = 0; i < count; i++) pos = wordEnd(lineText, pos);
762
- yankStart = char;
763
- yankEnd = Math.min(pos + 1, lineText.length);
764
- } else if (key === 'b') {
765
- let pos = char;
766
- for (let i = 0; i < count; i++) pos = wordBackward(lineText, pos);
767
- yankStart = pos;
768
- yankEnd = char;
769
- } else if (key === '$') {
770
- yankStart = char;
771
- yankEnd = lineText.length;
772
- } else if (key === '0') {
773
- yankStart = 0;
774
- yankEnd = char;
775
- } else if (key === '^') {
776
- const fnb = firstNonBlank(lineText);
777
- yankStart = Math.min(char, fnb);
778
- yankEnd = Math.max(char, fnb);
779
- } else if (key === 'h') {
780
- yankStart = Math.max(0, char - count);
781
- yankEnd = char;
782
- } else if (key === 'l') {
783
- yankStart = char;
784
- yankEnd = Math.min(char + count, lineText.length);
757
+ if (key === "f" || key === "F" || key === "t" || key === "T") {
758
+ pendingKey = "y" + key;
759
+ return true;
785
760
  }
786
761
 
787
- if (yankEnd > yankStart && yankStart !== -1) {
788
- setRegister(lineText.slice(yankStart, yankEnd));
762
+ const range = motionRange(key, char, lineText, count);
763
+ if (range && range.end > range.start) {
764
+ setRegister(lineText.slice(range.start, range.end));
789
765
  }
790
766
  return true;
791
767
  }
792
768
 
793
- if (pendingKey === 'm') {
769
+ if (pendingKey === "m") {
794
770
  pendingKey = null;
795
- if (key >= 'a' && key <= 'z') {
771
+ if (key >= "a" && key <= "z") {
796
772
  marks[key] = [line, char];
797
773
  }
798
774
  return true;
799
775
  }
800
776
 
801
- if (pendingKey === "'" || pendingKey === '`') {
777
+ if (pendingKey === "'" || pendingKey === "`") {
802
778
  const jumpType = pendingKey;
803
779
  pendingKey = null;
804
- if (key >= 'a' && key <= 'z' && marks[key]) {
780
+ if (key >= "a" && key <= "z" && marks[key]) {
805
781
  const [markLine, markChar] = marks[key];
782
+ desiredColumn = null;
806
783
  if (jumpType === "'") {
807
784
  const targetLineText = getLineText(rep, markLine);
808
785
  moveBlockCursor(editorInfo, markLine, firstNonBlank(targetLineText));
@@ -813,118 +790,146 @@ const handleNormalKey = (rep, editorInfo, key) => {
813
790
  return true;
814
791
  }
815
792
 
816
- if (key === 'h') {
793
+ if (key === "h") {
794
+ desiredColumn = null;
817
795
  moveBlockCursor(editorInfo, line, Math.max(0, char - count));
818
796
  return true;
819
797
  }
820
798
 
821
- if (key === 'l') {
799
+ if (key === "l") {
800
+ desiredColumn = null;
822
801
  moveBlockCursor(editorInfo, line, clampChar(char + count, lineText));
823
802
  return true;
824
803
  }
825
804
 
826
- if (key === 'k') {
805
+ if (key === "k") {
806
+ if (desiredColumn === null) {
807
+ desiredColumn = char;
808
+ }
827
809
  const newLine = clampLine(line - count, rep);
828
810
  const newLineText = getLineText(rep, newLine);
829
- moveBlockCursor(editorInfo, newLine, clampChar(char, newLineText));
811
+ moveBlockCursor(editorInfo, newLine, clampChar(desiredColumn, newLineText));
830
812
  return true;
831
813
  }
832
814
 
833
- if (key === 'j') {
815
+ if (key === "j") {
816
+ if (desiredColumn === null) {
817
+ desiredColumn = char;
818
+ }
834
819
  const newLine = clampLine(line + count, rep);
835
820
  const newLineText = getLineText(rep, newLine);
836
- moveBlockCursor(editorInfo, newLine, clampChar(char, newLineText));
821
+ moveBlockCursor(editorInfo, newLine, clampChar(desiredColumn, newLineText));
837
822
  return true;
838
823
  }
839
824
 
840
- if (key === '0') {
825
+ if (key === "0") {
826
+ desiredColumn = null;
841
827
  moveBlockCursor(editorInfo, line, 0);
842
828
  return true;
843
829
  }
844
830
 
845
- if (key === '$') {
831
+ if (key === "$") {
832
+ desiredColumn = null;
846
833
  moveBlockCursor(editorInfo, line, clampChar(lineText.length - 1, lineText));
847
834
  return true;
848
835
  }
849
836
 
850
- if (key === '^') {
837
+ if (key === "^") {
838
+ desiredColumn = null;
851
839
  moveBlockCursor(editorInfo, line, firstNonBlank(lineText));
852
840
  return true;
853
841
  }
854
842
 
855
- if (key === 'x') {
843
+ if (key === "x") {
856
844
  if (lineText.length > 0) {
857
845
  const deleteCount = Math.min(count, lineText.length - char);
858
- replaceRange(editorInfo, [line, char], [line, char + deleteCount], '');
846
+ replaceRange(editorInfo, [line, char], [line, char + deleteCount], "");
859
847
  const newLineText = getLineText(rep, line);
860
848
  moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
861
849
  }
862
850
  return true;
863
851
  }
864
852
 
865
- if (key === 'w') {
853
+ if (key === "w") {
854
+ desiredColumn = null;
866
855
  let pos = char;
867
856
  for (let i = 0; i < count; i++) pos = wordForward(lineText, pos);
868
857
  moveBlockCursor(editorInfo, line, clampChar(pos, lineText));
869
858
  return true;
870
859
  }
871
860
 
872
- if (key === 'b') {
861
+ if (key === "b") {
862
+ desiredColumn = null;
873
863
  let pos = char;
874
864
  for (let i = 0; i < count; i++) pos = wordBackward(lineText, pos);
875
865
  moveBlockCursor(editorInfo, line, pos);
876
866
  return true;
877
867
  }
878
868
 
879
- if (key === 'o') {
880
- replaceRange(editorInfo, [line, lineText.length], [line, lineText.length], '\n');
869
+ if (key === "o") {
870
+ replaceRange(
871
+ editorInfo,
872
+ [line, lineText.length],
873
+ [line, lineText.length],
874
+ "\n",
875
+ );
881
876
  moveCursor(editorInfo, line + 1, 0);
882
877
  setInsertMode(true);
883
878
  return true;
884
879
  }
885
880
 
886
- if (key === 'O') {
887
- replaceRange(editorInfo, [line, 0], [line, 0], '\n');
881
+ if (key === "O") {
882
+ replaceRange(editorInfo, [line, 0], [line, 0], "\n");
888
883
  moveCursor(editorInfo, line, 0);
889
884
  setInsertMode(true);
890
885
  return true;
891
886
  }
892
887
 
893
- if (key === 'u') {
888
+ if (key === "u") {
894
889
  undo(editorInfo);
895
890
  return true;
896
891
  }
897
892
 
898
- if (key === 'p') {
893
+ if (key === "p") {
899
894
  if (register !== null) {
900
- if (typeof register === 'string') {
895
+ if (typeof register === "string") {
901
896
  const insertPos = Math.min(char + 1, lineText.length);
902
897
  const repeated = register.repeat(count);
903
- replaceRange(editorInfo, [line, insertPos], [line, insertPos], repeated);
898
+ replaceRange(
899
+ editorInfo,
900
+ [line, insertPos],
901
+ [line, insertPos],
902
+ repeated,
903
+ );
904
904
  moveBlockCursor(editorInfo, line, insertPos);
905
905
  } else {
906
- const block = register.join('\n');
906
+ const block = register.join("\n");
907
907
  const parts = [];
908
908
  for (let i = 0; i < count; i++) parts.push(block);
909
- const insertText = '\n' + parts.join('\n');
910
- replaceRange(editorInfo, [line, lineText.length], [line, lineText.length], insertText);
909
+ const insertText = "\n" + parts.join("\n");
910
+ replaceRange(
911
+ editorInfo,
912
+ [line, lineText.length],
913
+ [line, lineText.length],
914
+ insertText,
915
+ );
911
916
  moveBlockCursor(editorInfo, line + 1, 0);
912
917
  }
913
918
  }
914
919
  return true;
915
920
  }
916
921
 
917
- if (key === 'P') {
922
+ if (key === "P") {
918
923
  if (register !== null) {
919
- if (typeof register === 'string') {
924
+ if (typeof register === "string") {
920
925
  const repeated = register.repeat(count);
921
926
  replaceRange(editorInfo, [line, char], [line, char], repeated);
922
927
  moveBlockCursor(editorInfo, line, char);
923
928
  } else {
924
- const block = register.join('\n');
929
+ const block = register.join("\n");
925
930
  const parts = [];
926
931
  for (let i = 0; i < count; i++) parts.push(block);
927
- const insertText = parts.join('\n') + '\n';
932
+ const insertText = parts.join("\n") + "\n";
928
933
  replaceRange(editorInfo, [line, 0], [line, 0], insertText);
929
934
  moveBlockCursor(editorInfo, line, 0);
930
935
  }
@@ -932,7 +937,8 @@ const handleNormalKey = (rep, editorInfo, key) => {
932
937
  return true;
933
938
  }
934
939
 
935
- if (key === 'G') {
940
+ if (key === "G") {
941
+ desiredColumn = null;
936
942
  if (pendingCount !== null) {
937
943
  moveBlockCursor(editorInfo, clampLine(pendingCount - 1, rep), 0);
938
944
  } else {
@@ -941,100 +947,191 @@ const handleNormalKey = (rep, editorInfo, key) => {
941
947
  return true;
942
948
  }
943
949
 
944
- if (key === 'g') {
945
- if (pendingKey === 'g') {
950
+ if (key === "g") {
951
+ if (pendingKey === "g") {
946
952
  pendingKey = null;
953
+ desiredColumn = null;
947
954
  moveBlockCursor(editorInfo, 0, 0);
948
955
  } else {
949
- pendingKey = 'g';
956
+ pendingKey = "g";
950
957
  }
951
958
  return true;
952
959
  }
953
960
 
954
- if (key === 'r') {
961
+ if (key === "r") {
955
962
  if (lineText.length > 0) {
956
- pendingKey = 'r';
963
+ pendingKey = "r";
957
964
  }
958
965
  return true;
959
966
  }
960
967
 
961
- if (key === 'f' || key === 'F' || key === 't' || key === 'T') {
968
+ if (key === "f" || key === "F" || key === "t" || key === "T") {
962
969
  pendingKey = key;
963
970
  return true;
964
971
  }
965
972
 
966
- if (key === 'm') {
967
- pendingKey = 'm';
973
+ if (key === "m") {
974
+ pendingKey = "m";
968
975
  return true;
969
976
  }
970
977
 
971
- if (key === "'" || key === '`') {
978
+ if (key === "'" || key === "`") {
972
979
  pendingKey = key;
973
980
  return true;
974
981
  }
975
982
 
976
- if (key === 'd') {
977
- pendingKey = 'd';
983
+ if (key === "d") {
984
+ pendingKey = "d";
978
985
  return true;
979
986
  }
980
987
 
981
- if (key === 'c') {
982
- pendingKey = 'c';
988
+ if (key === "c") {
989
+ pendingKey = "c";
983
990
  return true;
984
991
  }
985
992
 
986
- if (key === 'y') {
987
- pendingKey = 'y';
993
+ if (key === "y") {
994
+ pendingKey = "y";
988
995
  return true;
989
996
  }
990
997
 
991
- if (key === 'Y') {
998
+ if (key === "Y") {
992
999
  setRegister([lineText]);
993
1000
  return true;
994
1001
  }
995
1002
 
996
- if (key === 'J') {
1003
+ if (key === "J") {
997
1004
  const joins = Math.min(count, lineCount - 1 - line);
998
1005
  let cursorChar = lineText.length;
999
1006
  for (let i = 0; i < joins; i++) {
1000
1007
  const curLineText = getLineText(rep, line);
1001
1008
  const nextLineText = getLineText(rep, line + 1);
1002
- const trimmedNext = nextLineText.replace(/^\s+/, '');
1003
- const separator = curLineText.length === 0 ? '' : ' ';
1009
+ const trimmedNext = nextLineText.replace(/^\s+/, "");
1010
+ const separator = curLineText.length === 0 ? "" : " ";
1004
1011
  if (i === 0) cursorChar = curLineText.length;
1005
- replaceRange(editorInfo, [line, curLineText.length], [line + 1, nextLineText.length], separator + trimmedNext);
1012
+ replaceRange(
1013
+ editorInfo,
1014
+ [line, curLineText.length],
1015
+ [line + 1, nextLineText.length],
1016
+ separator + trimmedNext,
1017
+ );
1006
1018
  }
1007
1019
  moveBlockCursor(editorInfo, line, cursorChar);
1008
1020
  return true;
1009
1021
  }
1010
1022
 
1011
- if (key === 'C') {
1023
+ if (key === "~") {
1024
+ if (lineText.length > 0) {
1025
+ const toggleCount = Math.min(count, lineText.length - char);
1026
+ const slice = lineText.slice(char, char + toggleCount);
1027
+ let toggled = "";
1028
+ for (let i = 0; i < slice.length; i++) {
1029
+ const ch = slice[i];
1030
+ toggled +=
1031
+ ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
1032
+ }
1033
+ replaceRange(
1034
+ editorInfo,
1035
+ [line, char],
1036
+ [line, char + toggleCount],
1037
+ toggled,
1038
+ );
1039
+ const newChar = Math.min(char + toggleCount, lineText.length - 1);
1040
+ moveBlockCursor(editorInfo, line, newChar);
1041
+ }
1042
+ return true;
1043
+ }
1044
+
1045
+ if (key === "D") {
1046
+ setRegister(lineText.slice(char));
1047
+ replaceRange(editorInfo, [line, char], [line, lineText.length], "");
1048
+ const newLineText = getLineText(rep, line);
1049
+ moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
1050
+ return true;
1051
+ }
1052
+
1053
+ if (key === "C") {
1012
1054
  setRegister(lineText.slice(char));
1013
- replaceRange(editorInfo, [line, char], [line, lineText.length], '');
1055
+ replaceRange(editorInfo, [line, char], [line, lineText.length], "");
1014
1056
  moveCursor(editorInfo, line, char);
1015
1057
  setInsertMode(true);
1016
1058
  return true;
1017
1059
  }
1018
1060
 
1019
- if (key === 's') {
1061
+ if (key === "s") {
1020
1062
  setRegister(lineText.slice(char, char + 1));
1021
- replaceRange(editorInfo, [line, char], [line, Math.min(char + count, lineText.length)], '');
1063
+ replaceRange(
1064
+ editorInfo,
1065
+ [line, char],
1066
+ [line, Math.min(char + count, lineText.length)],
1067
+ "",
1068
+ );
1022
1069
  moveCursor(editorInfo, line, char);
1023
1070
  setInsertMode(true);
1024
1071
  return true;
1025
1072
  }
1026
1073
 
1027
- if (key === 'S') {
1074
+ if (key === "S") {
1028
1075
  setRegister(lineText);
1029
- replaceRange(editorInfo, [line, 0], [line, lineText.length], '');
1076
+ replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
1030
1077
  moveCursor(editorInfo, line, 0);
1031
1078
  setInsertMode(true);
1032
1079
  return true;
1033
1080
  }
1034
1081
 
1082
+ if (key === ";") {
1083
+ if (lastCharSearch) {
1084
+ const pos = charSearchPos(
1085
+ lastCharSearch.direction,
1086
+ lineText,
1087
+ char,
1088
+ lastCharSearch.target,
1089
+ count,
1090
+ );
1091
+ if (pos !== -1) {
1092
+ desiredColumn = null;
1093
+ moveBlockCursor(editorInfo, line, pos);
1094
+ }
1095
+ }
1096
+ return true;
1097
+ }
1098
+
1099
+ if (key === ",") {
1100
+ if (lastCharSearch) {
1101
+ const opposite = { f: "F", F: "f", t: "T", T: "t" };
1102
+ const reverseDir = opposite[lastCharSearch.direction];
1103
+ const pos = charSearchPos(
1104
+ reverseDir,
1105
+ lineText,
1106
+ char,
1107
+ lastCharSearch.target,
1108
+ count,
1109
+ );
1110
+ if (pos !== -1) {
1111
+ desiredColumn = null;
1112
+ moveBlockCursor(editorInfo, line, pos);
1113
+ }
1114
+ }
1115
+ return true;
1116
+ }
1117
+
1118
+ if (key === "}") {
1119
+ desiredColumn = null;
1120
+ const target = paragraphForward(rep, line, count);
1121
+ moveBlockCursor(editorInfo, target, 0);
1122
+ return true;
1123
+ }
1124
+
1125
+ if (key === "{") {
1126
+ desiredColumn = null;
1127
+ const target = paragraphBackward(rep, line, count);
1128
+ moveBlockCursor(editorInfo, target, 0);
1129
+ return true;
1130
+ }
1131
+
1035
1132
  pendingKey = null;
1036
1133
 
1037
- if (key === 'e') {
1134
+ if (key === "e") {
1038
1135
  let pos = char;
1039
1136
  for (let i = 0; i < count; i++) pos = wordEnd(lineText, pos);
1040
1137
  moveBlockCursor(editorInfo, line, clampChar(pos, lineText));
@@ -1046,22 +1143,33 @@ const handleNormalKey = (rep, editorInfo, key) => {
1046
1143
 
1047
1144
  // --- Exports ---
1048
1145
 
1049
- exports.aceEditorCSS = () => ['ep_vim/static/css/vim.css'];
1146
+ exports.aceEditorCSS = () => ["ep_vim/static/css/vim.css"];
1050
1147
 
1051
1148
  exports.postToolbarInit = (_hookName, _args) => {
1052
- const btn = document.getElementById('vim-toggle-btn');
1149
+ const btn = document.getElementById("vim-toggle-btn");
1053
1150
  if (!btn) return;
1054
- btn.classList.toggle('vim-enabled', vimEnabled);
1055
- btn.addEventListener('click', () => {
1151
+ btn.classList.toggle("vim-enabled", vimEnabled);
1152
+ btn.addEventListener("click", () => {
1056
1153
  vimEnabled = !vimEnabled;
1057
- localStorage.setItem('ep_vimEnabled', vimEnabled ? 'true' : 'false');
1058
- btn.classList.toggle('vim-enabled', vimEnabled);
1154
+ localStorage.setItem("ep_vimEnabled", vimEnabled ? "true" : "false");
1155
+ btn.classList.toggle("vim-enabled", vimEnabled);
1059
1156
  });
1060
1157
  };
1061
1158
 
1062
- exports.aceKeyEvent = (_hookName, {evt, rep, editorInfo}) => {
1159
+ exports.postAceInit = (_hookName, { ace }) => {
1160
+ if (!vimEnabled) return;
1161
+ ace.callWithAce((aceTop) => {
1162
+ const rep = aceTop.ace_getRep();
1163
+ if (rep && rep.selStart) {
1164
+ currentRep = rep;
1165
+ selectRange(aceTop, rep.selStart, [rep.selStart[0], rep.selStart[1] + 1]);
1166
+ }
1167
+ });
1168
+ };
1169
+
1170
+ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1063
1171
  if (!vimEnabled) return false;
1064
- if (evt.type !== 'keydown') return false;
1172
+ if (evt.type !== "keydown") return false;
1065
1173
  currentRep = rep;
1066
1174
  if (!editorDoc) {
1067
1175
  editorDoc = evt.target.ownerDocument;
@@ -1080,8 +1188,19 @@ exports.aceKeyEvent = (_hookName, {evt, rep, editorInfo}) => {
1080
1188
  return handled || true;
1081
1189
  }
1082
1190
 
1083
- if (!insertMode && evt.key === 'i') {
1191
+ if (
1192
+ !insertMode &&
1193
+ visualMode !== null &&
1194
+ (evt.key === "i" || evt.key === "a")
1195
+ ) {
1196
+ const handled = handleVisualKey(rep, editorInfo, evt.key);
1197
+ evt.preventDefault();
1198
+ return handled || true;
1199
+ }
1200
+
1201
+ if (!insertMode && evt.key === "i") {
1084
1202
  const [line, char] = rep.selStart;
1203
+ desiredColumn = null;
1085
1204
  moveCursor(editorInfo, line, char);
1086
1205
  setVisualMode(null);
1087
1206
  setInsertMode(true);
@@ -1089,9 +1208,10 @@ exports.aceKeyEvent = (_hookName, {evt, rep, editorInfo}) => {
1089
1208
  return true;
1090
1209
  }
1091
1210
 
1092
- if (!insertMode && evt.key === 'a') {
1211
+ if (!insertMode && evt.key === "a") {
1093
1212
  const [line, char] = rep.selStart;
1094
1213
  const lineText = getLineText(rep, line);
1214
+ desiredColumn = null;
1095
1215
  moveCursor(editorInfo, line, Math.min(char + 1, lineText.length));
1096
1216
  setVisualMode(null);
1097
1217
  setInsertMode(true);
@@ -1099,9 +1219,10 @@ exports.aceKeyEvent = (_hookName, {evt, rep, editorInfo}) => {
1099
1219
  return true;
1100
1220
  }
1101
1221
 
1102
- if (!insertMode && evt.key === 'A') {
1222
+ if (!insertMode && evt.key === "A") {
1103
1223
  const [line] = rep.selStart;
1104
1224
  const lineText = getLineText(rep, line);
1225
+ desiredColumn = null;
1105
1226
  moveCursor(editorInfo, line, lineText.length);
1106
1227
  setVisualMode(null);
1107
1228
  setInsertMode(true);
@@ -1109,9 +1230,10 @@ exports.aceKeyEvent = (_hookName, {evt, rep, editorInfo}) => {
1109
1230
  return true;
1110
1231
  }
1111
1232
 
1112
- if (!insertMode && evt.key === 'I') {
1233
+ if (!insertMode && evt.key === "I") {
1113
1234
  const [line] = rep.selStart;
1114
1235
  const lineText = getLineText(rep, line);
1236
+ desiredColumn = null;
1115
1237
  moveCursor(editorInfo, line, firstNonBlank(lineText));
1116
1238
  setVisualMode(null);
1117
1239
  setInsertMode(true);
@@ -1119,7 +1241,7 @@ exports.aceKeyEvent = (_hookName, {evt, rep, editorInfo}) => {
1119
1241
  return true;
1120
1242
  }
1121
1243
 
1122
- if (evt.key === 'Escape') {
1244
+ if (evt.key === "Escape") {
1123
1245
  if (insertMode) {
1124
1246
  setInsertMode(false);
1125
1247
  const [line, char] = rep.selStart;
@@ -1130,28 +1252,29 @@ exports.aceKeyEvent = (_hookName, {evt, rep, editorInfo}) => {
1130
1252
  setVisualMode(null);
1131
1253
  moveBlockCursor(editorInfo, vLine, vChar);
1132
1254
  }
1133
- countBuffer = '';
1255
+ countBuffer = "";
1134
1256
  pendingKey = null;
1135
1257
  pendingCount = null;
1258
+ desiredColumn = null;
1136
1259
  evt.preventDefault();
1137
1260
  return true;
1138
1261
  }
1139
1262
 
1140
- if (!insertMode && visualMode === null && evt.key === 'V') {
1263
+ if (!insertMode && visualMode === null && evt.key === "V") {
1141
1264
  const [line] = rep.selStart;
1142
1265
  visualAnchor = [line, 0];
1143
1266
  visualCursor = [line, 0];
1144
- setVisualMode('line');
1267
+ setVisualMode("line");
1145
1268
  updateVisualSelection(editorInfo, rep);
1146
1269
  evt.preventDefault();
1147
1270
  return true;
1148
1271
  }
1149
1272
 
1150
- if (!insertMode && visualMode === null && evt.key === 'v') {
1273
+ if (!insertMode && visualMode === null && evt.key === "v") {
1151
1274
  const [line, char] = rep.selStart;
1152
1275
  visualAnchor = [line, char];
1153
1276
  visualCursor = [line, char];
1154
- setVisualMode('char');
1277
+ setVisualMode("char");
1155
1278
  updateVisualSelection(editorInfo, rep);
1156
1279
  evt.preventDefault();
1157
1280
  return true;