ep_vim 0.7.1 → 0.9.2

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.
@@ -9,48 +9,206 @@ const {
9
9
  wordBackward,
10
10
  wordEnd,
11
11
  charSearchPos,
12
- motionRange,
13
12
  charMotionRange,
13
+ paragraphForward,
14
+ paragraphBackward,
15
+ matchingBracketPos,
14
16
  textWordRange,
15
17
  textQuoteRange,
16
18
  textBracketRange,
17
- getVisualSelection,
18
- paragraphForward,
19
- paragraphBackward,
20
19
  getTextInRange,
21
- matchingBracketPos,
20
+ getVisualSelection,
22
21
  paragraphTextRange,
23
22
  sentenceTextRange,
23
+ searchForward,
24
+ searchBackward,
24
25
  } = require("./vim-core");
25
26
 
26
- // --- State variables ---
27
-
28
- let vimEnabled = localStorage.getItem("ep_vimEnabled") === "true";
29
- let insertMode = false;
30
- let visualMode = null;
31
- let visualAnchor = null;
32
- let visualCursor = null;
33
- let pendingKey = null;
34
- let pendingOperator = null;
35
- let pendingCount = null;
36
- let countBuffer = "";
37
- let register = null;
38
- let marks = {};
39
- let editorDoc = null;
40
- let currentRep = null;
41
- let desiredColumn = null;
42
- let lastCharSearch = null;
43
-
44
- const QUOTE_CHARS = new Set(['"', "'"]);
45
- const BRACKET_CHARS = new Set(["(", ")", "{", "}", "[", "]"]);
46
-
47
- const textObjectRange = (key, lineText, char, type) => {
48
- if (key === "w") return textWordRange(lineText, char, type);
49
- if (QUOTE_CHARS.has(key)) return textQuoteRange(lineText, char, key, type);
50
- if (BRACKET_CHARS.has(key))
51
- return textBracketRange(lineText, char, key, type);
27
+ // --- State ---
28
+
29
+ let vimEnabled =
30
+ typeof localStorage !== "undefined" &&
31
+ localStorage.getItem("ep_vimEnabled") === "true";
32
+
33
+ const state = {
34
+ mode: "normal",
35
+ pendingKey: null,
36
+ pendingCount: null,
37
+ countBuffer: "",
38
+ register: null,
39
+ marks: {},
40
+ lastCharSearch: null,
41
+ visualAnchor: null,
42
+ visualCursor: null,
43
+ editorDoc: null,
44
+ currentRep: null,
45
+ desiredColumn: null,
46
+ lastCommand: null,
47
+ searchMode: false,
48
+ searchBuffer: "",
49
+ searchDirection: null,
50
+ lastSearch: null,
51
+ };
52
+
53
+ // --- Editor operations ---
54
+
55
+ const setRegister = (value) => {
56
+ state.register = value;
57
+ const text = Array.isArray(value) ? value.join("\n") + "\n" : value;
58
+ if (navigator.clipboard) {
59
+ navigator.clipboard.writeText(text).catch(() => {});
60
+ }
61
+ };
62
+
63
+ const moveCursor = (editorInfo, line, char) => {
64
+ const pos = [line, char];
65
+ editorInfo.ace_inCallStackIfNecessary("vim-move", () => {
66
+ editorInfo.ace_performSelectionChange(pos, pos, false);
67
+ editorInfo.ace_updateBrowserSelectionFromRep();
68
+ });
69
+ };
70
+
71
+ const replaceRange = (editorInfo, start, end, text) => {
72
+ editorInfo.ace_inCallStackIfNecessary("vim-edit", () => {
73
+ editorInfo.ace_performDocumentReplaceRange(start, end, text);
74
+ });
75
+ };
76
+
77
+ const selectRange = (editorInfo, start, end) => {
78
+ editorInfo.ace_inCallStackIfNecessary("vim-select", () => {
79
+ editorInfo.ace_performSelectionChange(start, end, false);
80
+ editorInfo.ace_updateBrowserSelectionFromRep();
81
+ });
82
+ };
83
+
84
+ const updateVisualSelection = (editorInfo, rep) => {
85
+ const vMode = state.mode === "visual-line" ? "line" : "char";
86
+ const [start, end] = getVisualSelection(
87
+ vMode,
88
+ state.visualAnchor,
89
+ state.visualCursor,
90
+ rep,
91
+ );
92
+ if (vMode === "char") {
93
+ selectRange(editorInfo, start, [end[0], end[1] + 1]);
94
+ } else {
95
+ selectRange(editorInfo, start, end);
96
+ }
97
+ };
98
+
99
+ const clearEmptyLineCursor = () => {
100
+ if (!state.editorDoc) return;
101
+ const old = state.editorDoc.querySelector(".vim-empty-line-cursor");
102
+ if (old) old.classList.remove("vim-empty-line-cursor");
103
+ };
104
+
105
+ const scrollLineIntoView = (line) => {
106
+ if (!state.editorDoc) return;
107
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
108
+ if (lineDiv) lineDiv.scrollIntoView({ block: "nearest" });
109
+ };
110
+
111
+ const moveBlockCursor = (editorInfo, line, char) => {
112
+ clearEmptyLineCursor();
113
+ const lineText = state.currentRep ? getLineText(state.currentRep, line) : "";
114
+ if (lineText.length === 0 && state.editorDoc) {
115
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
116
+ if (lineDiv) lineDiv.classList.add("vim-empty-line-cursor");
117
+ selectRange(editorInfo, [line, 0], [line, 0]);
118
+ } else {
119
+ selectRange(editorInfo, [line, char], [line, char + 1]);
120
+ }
121
+ scrollLineIntoView(line);
122
+ };
123
+
124
+ const moveVisualCursor = (editorInfo, rep, line, char) => {
125
+ state.visualCursor = [line, char];
126
+ updateVisualSelection(editorInfo, rep);
127
+ scrollLineIntoView(line);
52
128
  };
53
129
 
130
+ // --- Line helpers ---
131
+
132
+ const deleteLines = (editorInfo, rep, topLine, bottomLine) => {
133
+ const totalLines = rep.lines.length();
134
+ if (bottomLine === totalLines - 1 && topLine > 0) {
135
+ const prevLineLen = getLineText(rep, topLine - 1).length;
136
+ replaceRange(
137
+ editorInfo,
138
+ [topLine - 1, prevLineLen],
139
+ [bottomLine, getLineText(rep, bottomLine).length],
140
+ "",
141
+ );
142
+ return topLine - 1;
143
+ }
144
+ if (bottomLine < totalLines - 1) {
145
+ replaceRange(editorInfo, [topLine, 0], [bottomLine + 1, 0], "");
146
+ return topLine;
147
+ }
148
+ replaceRange(
149
+ editorInfo,
150
+ [0, 0],
151
+ [bottomLine, getLineText(rep, bottomLine).length],
152
+ "",
153
+ );
154
+ return 0;
155
+ };
156
+
157
+ const applyLineOperator = (op, topLine, bottomLine, ctx) => {
158
+ const { editorInfo, rep, char } = ctx;
159
+ const lines = [];
160
+ for (let i = topLine; i <= bottomLine; i++) lines.push(getLineText(rep, i));
161
+ setRegister(lines);
162
+ if (op === "y") {
163
+ moveBlockCursor(editorInfo, topLine, 0);
164
+ return;
165
+ }
166
+ if (op === "c") {
167
+ if (bottomLine > topLine) {
168
+ deleteLines(editorInfo, rep, topLine + 1, bottomLine);
169
+ }
170
+ const text = getLineText(rep, topLine);
171
+ replaceRange(editorInfo, [topLine, 0], [topLine, text.length], "");
172
+ moveCursor(editorInfo, topLine, 0);
173
+ state.mode = "insert";
174
+ return;
175
+ }
176
+ const cursorLine = deleteLines(editorInfo, rep, topLine, bottomLine);
177
+ const newLineText = getLineText(rep, cursorLine);
178
+ moveBlockCursor(editorInfo, cursorLine, clampChar(char, newLineText));
179
+ };
180
+
181
+ // --- Operator helper ---
182
+
183
+ const applyOperator = (op, start, end, ctx) => {
184
+ const { editorInfo, rep } = ctx;
185
+ const before =
186
+ start[0] < end[0] || (start[0] === end[0] && start[1] <= end[1]);
187
+ const [s, e] = before ? [start, end] : [end, start];
188
+ setRegister(getTextInRange(rep, s, e));
189
+ if (op === "y") {
190
+ moveBlockCursor(editorInfo, s[0], s[1]);
191
+ return;
192
+ }
193
+ replaceRange(editorInfo, s, e, "");
194
+ if (op === "c") {
195
+ moveCursor(editorInfo, s[0], s[1]);
196
+ state.mode = "insert";
197
+ } else {
198
+ moveBlockCursor(editorInfo, s[0], s[1]);
199
+ }
200
+ };
201
+
202
+ // --- Command tables ---
203
+ const commands = {
204
+ normal: {},
205
+ "visual-char": {},
206
+ "visual-line": {},
207
+ };
208
+
209
+ // --- Registration helpers ---
210
+ const OPERATORS = ["d", "c", "y"];
211
+
54
212
  const resolveTextObject = (key, type, line, lineText, char, rep) => {
55
213
  if (key === "p") {
56
214
  return paragraphTextRange(rep, line, type);
@@ -65,25 +223,128 @@ const resolveTextObject = (key, type, line, lineText, char, rep) => {
65
223
  endChar: r.end,
66
224
  };
67
225
  }
68
- const r = textObjectRange(key, lineText, char, type);
226
+ const r =
227
+ key === "w"
228
+ ? textWordRange(lineText, char, type)
229
+ : ["(", ")", "{", "}", "[", "]"].includes(key)
230
+ ? textBracketRange(lineText, char, key, type)
231
+ : ['"', "'", "`"].includes(key)
232
+ ? textQuoteRange(lineText, char, key, type)
233
+ : null;
69
234
  if (!r) return null;
70
235
  return { startLine: line, startChar: r.start, endLine: line, endChar: r.end };
71
236
  };
72
237
 
238
+ const recordCommand = (key, count, param = null) => {
239
+ state.lastCommand = { key, count, param };
240
+ };
241
+
242
+ const registerMotion = (
243
+ key,
244
+ getEndPos,
245
+ inclusive = false,
246
+ keepDesiredColumn = false,
247
+ ) => {
248
+ commands.normal[key] = (ctx) => {
249
+ if (!keepDesiredColumn) state.desiredColumn = null;
250
+ const pos = getEndPos(ctx);
251
+ if (pos) {
252
+ const lineText = getLineText(ctx.rep, pos.line);
253
+ moveBlockCursor(ctx.editorInfo, pos.line, clampChar(pos.char, lineText));
254
+ }
255
+ };
256
+ commands["visual-char"][key] = (ctx) => {
257
+ if (!keepDesiredColumn) state.desiredColumn = null;
258
+ const pos = getEndPos(ctx);
259
+ if (pos) moveVisualCursor(ctx.editorInfo, ctx.rep, pos.line, pos.char);
260
+ };
261
+ commands["visual-line"][key] = (ctx) => {
262
+ if (!keepDesiredColumn) state.desiredColumn = null;
263
+ const pos = getEndPos(ctx);
264
+ if (pos) moveVisualCursor(ctx.editorInfo, ctx.rep, pos.line, pos.char);
265
+ };
266
+ for (const op of OPERATORS) {
267
+ commands.normal[op + key] = (ctx) => {
268
+ state.desiredColumn = null;
269
+ const pos = getEndPos(ctx);
270
+ if (pos) {
271
+ const endChar = inclusive ? pos.char + 1 : pos.char;
272
+ applyOperator(op, [ctx.line, ctx.char], [pos.line, endChar], ctx);
273
+ recordCommand(op + key, ctx.count);
274
+ }
275
+ };
276
+ }
277
+ };
278
+
279
+ const parameterized = {};
280
+
281
+ const registerParamMotion = (key, getEndChar) => {
282
+ commands.normal[key] = () => {
283
+ state.pendingKey = key;
284
+ };
285
+ commands["visual-char"][key] = () => {
286
+ state.pendingKey = key;
287
+ };
288
+ commands["visual-line"][key] = () => {
289
+ state.pendingKey = key;
290
+ };
291
+ parameterized[key] = (argKey, ctx) => {
292
+ state.lastCharSearch = { direction: key, target: argKey };
293
+ const pos = getEndChar(argKey, ctx);
294
+ if (pos !== null) {
295
+ if (state.mode.startsWith("visual")) {
296
+ moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
297
+ } else {
298
+ moveBlockCursor(ctx.editorInfo, ctx.line, pos);
299
+ }
300
+ recordCommand(key, ctx.count, argKey);
301
+ }
302
+ };
303
+ for (const op of OPERATORS) {
304
+ const combo = op + key;
305
+ commands.normal[combo] = () => {
306
+ state.pendingKey = combo;
307
+ };
308
+ parameterized[combo] = (argKey, ctx) => {
309
+ state.lastCharSearch = { direction: key, target: argKey };
310
+ const pos = getEndChar(argKey, ctx);
311
+ if (pos !== null) {
312
+ const range = charMotionRange(key, ctx.char, pos);
313
+ if (range)
314
+ applyOperator(
315
+ op,
316
+ [ctx.line, range.start],
317
+ [ctx.line, range.end],
318
+ ctx,
319
+ );
320
+ recordCommand(combo, ctx.count, argKey);
321
+ }
322
+ };
323
+ }
324
+ };
325
+
326
+ const registerTextObject = (obj, getRange) => {
327
+ for (const op of OPERATORS) {
328
+ for (const type of ["i", "a"]) {
329
+ commands.normal[`${op}${type}${obj}`] = (ctx) => {
330
+ const range = getRange(ctx, type);
331
+ if (range) applyOperator(op, range.start, range.end, ctx);
332
+ };
333
+ }
334
+ }
335
+ };
336
+
73
337
  const getVisibleLineRange = (rep) => {
74
338
  const totalLines = rep.lines.length();
75
- if (!editorDoc) return { top: 0, bottom: totalLines - 1 };
76
- const lineDivs = editorDoc.body.querySelectorAll("div");
339
+ if (!state.editorDoc) {
340
+ const mid = Math.floor((totalLines - 1) / 2);
341
+ return { top: 0, mid, bottom: totalLines - 1 };
342
+ }
343
+ const lineDivs = state.editorDoc.body.querySelectorAll("div");
77
344
  const lineCount = Math.min(lineDivs.length, totalLines);
78
-
79
- // The iframe doesn't scroll — the outer page does. getBoundingClientRect()
80
- // inside the iframe is relative to the iframe document top (not the outer
81
- // viewport). We need the iframe's own position in the outer viewport to
82
- // know which lines are actually visible.
83
- const frameEl = editorDoc.defaultView.frameElement;
345
+ const frameEl = state.editorDoc.defaultView.frameElement;
84
346
  const iframeTop = frameEl ? frameEl.getBoundingClientRect().top : 0;
85
347
  const outerViewportHeight = window.parent ? window.parent.innerHeight : 600;
86
-
87
348
  let top = 0;
88
349
  let bottom = lineCount - 1;
89
350
  for (let i = 0; i < lineCount; i++) {
@@ -100,8 +361,6 @@ const getVisibleLineRange = (rep) => {
100
361
  break;
101
362
  }
102
363
  }
103
-
104
- // Lines can wrap, so find the middle by pixel position rather than index.
105
364
  const visibleTop = iframeTop + lineDivs[top].getBoundingClientRect().top;
106
365
  const visibleBottom =
107
366
  iframeTop + lineDivs[bottom].getBoundingClientRect().bottom;
@@ -114,815 +373,710 @@ const getVisibleLineRange = (rep) => {
114
373
  break;
115
374
  }
116
375
  }
117
-
118
376
  return { top, mid, bottom };
119
377
  };
120
378
 
121
- // --- Count helpers ---
379
+ // --- Motions ---
380
+ registerMotion("h", (ctx) => ({
381
+ line: ctx.line,
382
+ char: Math.max(0, ctx.char - ctx.count),
383
+ }));
384
+
385
+ registerMotion("l", (ctx) => ({
386
+ line: ctx.line,
387
+ char: clampChar(ctx.char + ctx.count, ctx.lineText),
388
+ }));
389
+
390
+ registerMotion(
391
+ "j",
392
+ (ctx) => {
393
+ if (state.desiredColumn === null) state.desiredColumn = ctx.char;
394
+ const newLine = clampLine(ctx.line + ctx.count, ctx.rep);
395
+ const newLineText = getLineText(ctx.rep, newLine);
396
+ return {
397
+ line: newLine,
398
+ char: clampChar(state.desiredColumn, newLineText),
399
+ };
400
+ },
401
+ false,
402
+ true,
403
+ );
404
+
405
+ registerMotion(
406
+ "k",
407
+ (ctx) => {
408
+ if (state.desiredColumn === null) state.desiredColumn = ctx.char;
409
+ const newLine = clampLine(ctx.line - ctx.count, ctx.rep);
410
+ const newLineText = getLineText(ctx.rep, newLine);
411
+ return {
412
+ line: newLine,
413
+ char: clampChar(state.desiredColumn, newLineText),
414
+ };
415
+ },
416
+ false,
417
+ true,
418
+ );
419
+
420
+ registerMotion("w", (ctx) => {
421
+ let pos = ctx.char;
422
+ for (let i = 0; i < ctx.count; i++) pos = wordForward(ctx.lineText, pos);
423
+ return { line: ctx.line, char: pos };
424
+ });
425
+
426
+ registerMotion("b", (ctx) => {
427
+ let pos = ctx.char;
428
+ for (let i = 0; i < ctx.count; i++) pos = wordBackward(ctx.lineText, pos);
429
+ return { line: ctx.line, char: pos };
430
+ });
431
+
432
+ registerMotion(
433
+ "e",
434
+ (ctx) => {
435
+ let pos = ctx.char;
436
+ for (let i = 0; i < ctx.count; i++) pos = wordEnd(ctx.lineText, pos);
437
+ return { line: ctx.line, char: clampChar(pos, ctx.lineText) };
438
+ },
439
+ true,
440
+ );
441
+
442
+ registerMotion("0", (_ctx) => ({ line: _ctx.line, char: 0 }));
443
+
444
+ registerMotion(
445
+ "$",
446
+ (ctx) => ({
447
+ line: ctx.line,
448
+ char: clampChar(ctx.lineText.length - 1, ctx.lineText),
449
+ }),
450
+ true,
451
+ );
452
+
453
+ registerMotion("^", (ctx) => ({
454
+ line: ctx.line,
455
+ char: firstNonBlank(ctx.lineText),
456
+ }));
457
+
458
+ registerMotion("gg", (ctx) => ({
459
+ line: ctx.hasCount ? clampLine(ctx.count - 1, ctx.rep) : 0,
460
+ char: 0,
461
+ }));
462
+
463
+ registerMotion("G", (ctx) => ({
464
+ line: ctx.hasCount
465
+ ? clampLine(ctx.count - 1, ctx.rep)
466
+ : ctx.rep.lines.length() - 1,
467
+ char: 0,
468
+ }));
469
+
470
+ registerMotion("{", (ctx) => ({
471
+ line: paragraphBackward(ctx.rep, ctx.line, ctx.count),
472
+ char: 0,
473
+ }));
474
+
475
+ registerMotion("}", (ctx) => ({
476
+ line: paragraphForward(ctx.rep, ctx.line, ctx.count),
477
+ char: 0,
478
+ }));
479
+
480
+ registerMotion(
481
+ "%",
482
+ (ctx) => {
483
+ const pos = matchingBracketPos(ctx.rep, ctx.line, ctx.char);
484
+ return pos || { line: ctx.line, char: ctx.char };
485
+ },
486
+ true,
487
+ );
488
+
489
+ registerMotion("H", (ctx) => {
490
+ const { top } = getVisibleLineRange(ctx.rep);
491
+ const targetLine = clampLine(top + ctx.count - 1, ctx.rep);
492
+ return {
493
+ line: targetLine,
494
+ char: firstNonBlank(getLineText(ctx.rep, targetLine)),
495
+ };
496
+ });
497
+
498
+ registerMotion("M", (ctx) => {
499
+ const { mid } = getVisibleLineRange(ctx.rep);
500
+ return {
501
+ line: mid,
502
+ char: firstNonBlank(getLineText(ctx.rep, mid)),
503
+ };
504
+ });
505
+
506
+ registerMotion("L", (ctx) => {
507
+ const { bottom } = getVisibleLineRange(ctx.rep);
508
+ const targetLine = clampLine(bottom - ctx.count + 1, ctx.rep);
509
+ return {
510
+ line: targetLine,
511
+ char: firstNonBlank(getLineText(ctx.rep, targetLine)),
512
+ };
513
+ });
514
+
515
+ registerParamMotion("f", (key, ctx) => {
516
+ const pos = charSearchPos("f", ctx.lineText, ctx.char, key, ctx.count);
517
+ return pos !== -1 ? pos : null;
518
+ });
519
+
520
+ registerParamMotion("F", (key, ctx) => {
521
+ const pos = charSearchPos("F", ctx.lineText, ctx.char, key, ctx.count);
522
+ return pos !== -1 ? pos : null;
523
+ });
524
+
525
+ registerParamMotion("t", (key, ctx) => {
526
+ const pos = charSearchPos("t", ctx.lineText, ctx.char, key, ctx.count);
527
+ return pos !== -1 ? pos : null;
528
+ });
529
+
530
+ registerParamMotion("T", (key, ctx) => {
531
+ const pos = charSearchPos("T", ctx.lineText, ctx.char, key, ctx.count);
532
+ return pos !== -1 ? pos : null;
533
+ });
534
+
535
+ const registerCharRepeat = (key, getDirection) => {
536
+ const handler = (ctx) => {
537
+ if (!state.lastCharSearch) return;
538
+ const dir = getDirection(state.lastCharSearch.direction);
539
+ const pos = charSearchPos(
540
+ dir,
541
+ ctx.lineText,
542
+ ctx.char,
543
+ state.lastCharSearch.target,
544
+ ctx.count,
545
+ );
546
+ if (pos === -1) return;
547
+ if (state.mode.startsWith("visual")) {
548
+ moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
549
+ } else {
550
+ moveBlockCursor(ctx.editorInfo, ctx.line, pos);
551
+ }
552
+ };
553
+ commands.normal[key] = handler;
554
+ commands["visual-char"][key] = handler;
555
+ commands["visual-line"][key] = handler;
556
+ };
122
557
 
123
- const consumeCount = () => {
124
- if (countBuffer !== "") {
125
- pendingCount = parseInt(countBuffer, 10);
126
- countBuffer = "";
127
- } else if (pendingKey === null && pendingOperator === null) {
128
- pendingCount = null;
129
- }
558
+ const sameDirection = (dir) => dir;
559
+ const oppositeDirection = {
560
+ f: "F",
561
+ F: "f",
562
+ t: "T",
563
+ T: "t",
130
564
  };
565
+ const reverseDirection = (dir) => oppositeDirection[dir];
131
566
 
132
- const getCount = () => pendingCount || 1;
567
+ registerCharRepeat(";", sameDirection);
568
+ registerCharRepeat(",", reverseDirection);
133
569
 
134
- // --- Side-effectful helpers ---
570
+ // --- Marks ---
135
571
 
136
- const setRegister = (value) => {
137
- register = value;
138
- const text = Array.isArray(value) ? value.join("\n") + "\n" : value;
139
- if (navigator.clipboard) {
140
- navigator.clipboard.writeText(text).catch(() => {});
141
- }
572
+ commands.normal["m"] = () => {
573
+ state.pendingKey = "m";
142
574
  };
143
-
144
- const moveCursor = (editorInfo, line, char) => {
145
- const pos = [line, char];
146
- editorInfo.ace_inCallStackIfNecessary("vim-move", () => {
147
- editorInfo.ace_performSelectionChange(pos, pos, false);
148
- editorInfo.ace_updateBrowserSelectionFromRep();
149
- });
575
+ parameterized["m"] = (key, ctx) => {
576
+ if (key >= "a" && key <= "z") state.marks[key] = [ctx.line, ctx.char];
150
577
  };
151
578
 
152
- const clearEmptyLineCursor = () => {
153
- if (!editorDoc) return;
154
- const old = editorDoc.querySelector(".vim-empty-line-cursor");
155
- if (old) old.classList.remove("vim-empty-line-cursor");
579
+ commands.normal["'"] = () => {
580
+ state.pendingKey = "'";
156
581
  };
157
-
158
- const moveBlockCursor = (editorInfo, line, char) => {
159
- clearEmptyLineCursor();
160
- const lineText = currentRep ? getLineText(currentRep, line) : "";
161
- if (lineText.length === 0 && editorDoc) {
162
- const lineDiv = editorDoc.body.querySelectorAll("div")[line];
163
- if (lineDiv) lineDiv.classList.add("vim-empty-line-cursor");
164
- selectRange(editorInfo, [line, 0], [line, 0]);
582
+ commands["visual-char"]["'"] = () => {
583
+ state.pendingKey = "'";
584
+ };
585
+ commands["visual-line"]["'"] = () => {
586
+ state.pendingKey = "'";
587
+ };
588
+ parameterized["'"] = (key, ctx) => {
589
+ if (!state.marks[key]) return;
590
+ const [markLine] = state.marks[key];
591
+ const targetText = getLineText(ctx.rep, markLine);
592
+ const targetChar = firstNonBlank(targetText);
593
+ if (state.mode.startsWith("visual")) {
594
+ moveVisualCursor(ctx.editorInfo, ctx.rep, markLine, targetChar);
165
595
  } else {
166
- selectRange(editorInfo, [line, char], [line, char + 1]);
596
+ moveBlockCursor(ctx.editorInfo, markLine, targetChar);
167
597
  }
168
598
  };
169
599
 
170
- const selectRange = (editorInfo, start, end) => {
171
- editorInfo.ace_inCallStackIfNecessary("vim-select", () => {
172
- editorInfo.ace_performSelectionChange(start, end, false);
173
- editorInfo.ace_updateBrowserSelectionFromRep();
174
- });
600
+ commands.normal["`"] = () => {
601
+ state.pendingKey = "`";
175
602
  };
176
-
177
- const replaceRange = (editorInfo, start, end, text) => {
178
- editorInfo.ace_inCallStackIfNecessary("vim-edit", () => {
179
- editorInfo.ace_performDocumentReplaceRange(start, end, text);
180
- });
603
+ commands["visual-char"]["`"] = () => {
604
+ state.pendingKey = "`";
181
605
  };
182
-
183
- const undo = (editorInfo) => {
184
- editorInfo.ace_doUndoRedo("undo");
185
- };
186
-
187
- // --- Mode management ---
188
-
189
- const setInsertMode = (value) => {
190
- insertMode = value;
191
- if (value) clearEmptyLineCursor();
192
- if (editorDoc) {
193
- editorDoc.body.classList.toggle("vim-insert-mode", value);
194
- }
606
+ commands["visual-line"]["`"] = () => {
607
+ state.pendingKey = "`";
195
608
  };
196
-
197
- const setVisualMode = (value) => {
198
- visualMode = value;
199
- if (editorDoc) {
200
- editorDoc.body.classList.toggle("vim-visual-line-mode", value === "line");
201
- editorDoc.body.classList.toggle("vim-visual-char-mode", value === "char");
609
+ parameterized["`"] = (key, ctx) => {
610
+ if (!state.marks[key]) return;
611
+ const [markLine, markChar] = state.marks[key];
612
+ if (state.mode.startsWith("visual")) {
613
+ moveVisualCursor(ctx.editorInfo, ctx.rep, markLine, markChar);
614
+ } else {
615
+ moveBlockCursor(ctx.editorInfo, markLine, markChar);
202
616
  }
203
617
  };
204
618
 
205
- const updateVisualSelection = (editorInfo, rep) => {
206
- const [start, end] = getVisualSelection(
207
- visualMode,
208
- visualAnchor,
209
- visualCursor,
210
- rep,
211
- );
212
- selectRange(editorInfo, start, end);
213
- };
214
-
215
- // --- Motion resolution (shared between normal and visual) ---
216
-
217
- const resolveMotion = (key, line, char, lineText, rep, count) => {
218
- if (
219
- pendingKey === "f" ||
220
- pendingKey === "F" ||
221
- pendingKey === "t" ||
222
- pendingKey === "T"
223
- ) {
224
- const direction = pendingKey;
225
- pendingKey = null;
226
- lastCharSearch = { direction, target: key };
227
- const pos = charSearchPos(direction, lineText, char, key, count);
228
- if (pos !== -1) {
229
- desiredColumn = null;
230
- return { line, char: pos };
231
- }
232
- return { line, char };
233
- }
234
-
235
- if (pendingKey === "'" || pendingKey === "`") {
236
- const jumpType = pendingKey;
237
- pendingKey = null;
238
- if (key >= "a" && key <= "z" && marks[key]) {
239
- const [markLine, markChar] = marks[key];
240
- desiredColumn = null;
241
- if (jumpType === "'") {
242
- const targetLineText = getLineText(rep, markLine);
243
- return { line: markLine, char: firstNonBlank(targetLineText) };
244
- }
245
- return { line: markLine, char: markChar };
246
- }
247
- return { line, char };
248
- }
249
-
250
- if (pendingKey === "g") {
251
- pendingKey = null;
252
- if (key === "g") {
253
- desiredColumn = null;
254
- if (pendingCount !== null) {
255
- return { line: clampLine(pendingCount - 1, rep), char: 0 };
256
- }
257
- return { line: 0, char: 0 };
258
- }
259
- }
260
-
261
- if (key === "h") {
262
- desiredColumn = null;
263
- return { line, char: Math.max(0, char - count) };
264
- }
265
-
266
- if (key === "l") {
267
- desiredColumn = null;
268
- return { line, char: clampChar(char + count, lineText) };
269
- }
270
-
271
- if (key === "j") {
272
- if (desiredColumn === null) desiredColumn = char;
273
- const newLine = clampLine(line + count, rep);
274
- const newLineText = getLineText(rep, newLine);
275
- return { line: newLine, char: clampChar(desiredColumn, newLineText) };
276
- }
277
-
278
- if (key === "k") {
279
- if (desiredColumn === null) desiredColumn = char;
280
- const newLine = clampLine(line - count, rep);
281
- const newLineText = getLineText(rep, newLine);
282
- return { line: newLine, char: clampChar(desiredColumn, newLineText) };
283
- }
284
-
285
- if (key === "w") {
286
- desiredColumn = null;
287
- let pos = char;
288
- for (let i = 0; i < count; i++) pos = wordForward(lineText, pos);
289
- return { line, char: clampChar(pos, lineText) };
290
- }
291
-
292
- if (key === "b") {
293
- desiredColumn = null;
294
- let pos = char;
295
- for (let i = 0; i < count; i++) pos = wordBackward(lineText, pos);
296
- return { line, char: pos };
297
- }
619
+ // --- Text objects ---
298
620
 
299
- if (key === "e") {
300
- desiredColumn = null;
301
- let pos = char;
302
- for (let i = 0; i < count; i++) pos = wordEnd(lineText, pos);
303
- return { line, char: clampChar(pos, lineText) };
304
- }
305
-
306
- if (key === "0") {
307
- desiredColumn = null;
308
- return { line, char: 0 };
309
- }
621
+ registerTextObject("w", (ctx, type) => {
622
+ const r = textWordRange(ctx.lineText, ctx.char, type);
623
+ if (!r) return null;
624
+ return { start: [ctx.line, r.start], end: [ctx.line, r.end] };
625
+ });
310
626
 
311
- if (key === "$") {
312
- desiredColumn = null;
313
- return { line, char: clampChar(lineText.length - 1, lineText) };
314
- }
627
+ for (const q of ['"', "'", "`"]) {
628
+ registerTextObject(q, (ctx, type) => {
629
+ const r = textQuoteRange(ctx.lineText, ctx.char, q, type);
630
+ if (!r) return null;
631
+ return { start: [ctx.line, r.start], end: [ctx.line, r.end] };
632
+ });
633
+ }
315
634
 
316
- if (key === "^") {
317
- desiredColumn = null;
318
- return { line, char: firstNonBlank(lineText) };
319
- }
635
+ for (const bracket of ["(", ")", "{", "}", "[", "]"]) {
636
+ registerTextObject(bracket, (ctx, type) => {
637
+ const r = textBracketRange(ctx.lineText, ctx.char, bracket, type);
638
+ if (!r) return null;
639
+ return { start: [ctx.line, r.start], end: [ctx.line, r.end] };
640
+ });
641
+ }
320
642
 
321
- if (key === "}") {
322
- desiredColumn = null;
323
- return { line: paragraphForward(rep, line, count), char: 0 };
324
- }
643
+ registerTextObject("p", (ctx, type) => {
644
+ const r = paragraphTextRange(ctx.rep, ctx.line, type);
645
+ if (!r) return null;
646
+ return {
647
+ start: [r.startLine, r.startChar],
648
+ end: [r.endLine, r.endChar],
649
+ };
650
+ });
651
+
652
+ registerTextObject("s", (ctx, type) => {
653
+ const r = sentenceTextRange(ctx.lineText, ctx.char, type);
654
+ if (!r) return null;
655
+ return { start: [ctx.line, r.start], end: [ctx.line, r.end] };
656
+ });
657
+
658
+ // --- Line operators ---
659
+
660
+ for (const op of OPERATORS) {
661
+ commands.normal[op + op] = (ctx) => {
662
+ const bottomLine = clampLine(ctx.line + ctx.count - 1, ctx.rep);
663
+ applyLineOperator(op, ctx.line, bottomLine, ctx);
664
+ recordCommand(op + op, ctx.count);
665
+ };
666
+ }
667
+
668
+ // --- Visual modes ---
669
+
670
+ commands.normal["v"] = ({ editorInfo, rep, line, char }) => {
671
+ state.visualAnchor = [line, char];
672
+ state.visualCursor = [line, char];
673
+ state.mode = "visual-char";
674
+ updateVisualSelection(editorInfo, rep);
675
+ };
325
676
 
326
- if (key === "{") {
327
- desiredColumn = null;
328
- return { line: paragraphBackward(rep, line, count), char: 0 };
329
- }
677
+ commands.normal["V"] = ({ editorInfo, rep, line }) => {
678
+ state.visualAnchor = [line, 0];
679
+ state.visualCursor = [line, 0];
680
+ state.mode = "visual-line";
681
+ updateVisualSelection(editorInfo, rep);
682
+ };
330
683
 
331
- if (key === "G") {
332
- desiredColumn = null;
333
- if (pendingCount !== null) {
334
- return { line: clampLine(pendingCount - 1, rep), char: 0 };
335
- }
336
- return { line: rep.lines.length() - 1, char: 0 };
337
- }
684
+ for (const op of OPERATORS) {
685
+ commands["visual-line"][op] = (ctx) => {
686
+ const topLine = Math.min(state.visualAnchor[0], state.visualCursor[0]);
687
+ const bottomLine = Math.max(state.visualAnchor[0], state.visualCursor[0]);
688
+ state.mode = "normal";
689
+ applyLineOperator(op, topLine, bottomLine, ctx);
690
+ };
691
+ }
692
+
693
+ for (const op of OPERATORS) {
694
+ commands["visual-char"][op] = (ctx) => {
695
+ const [start, end] = getVisualSelection(
696
+ "char",
697
+ state.visualAnchor,
698
+ state.visualCursor,
699
+ ctx.rep,
700
+ );
701
+ state.mode = "normal";
702
+ applyOperator(op, start, [end[0], end[1] + 1], ctx);
703
+ };
704
+ }
338
705
 
339
- if (key === ";") {
340
- if (lastCharSearch) {
341
- const pos = charSearchPos(
342
- lastCharSearch.direction,
343
- lineText,
344
- char,
345
- lastCharSearch.target,
346
- count,
347
- );
348
- if (pos !== -1) {
349
- desiredColumn = null;
350
- return { line, char: pos };
351
- }
352
- }
353
- return { line, char };
354
- }
706
+ commands["visual-char"]["~"] = (ctx) => {
707
+ const [start, end] = getVisualSelection(
708
+ "char",
709
+ state.visualAnchor,
710
+ state.visualCursor,
711
+ ctx.rep,
712
+ );
713
+ const adjustedEnd = [end[0], end[1] + 1];
714
+ const text = getTextInRange(ctx.rep, start, adjustedEnd);
715
+ let toggled = "";
716
+ for (let i = 0; i < text.length; i++) {
717
+ const ch = text[i];
718
+ toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
719
+ }
720
+ replaceRange(ctx.editorInfo, start, adjustedEnd, toggled);
721
+ state.mode = "normal";
722
+ moveBlockCursor(ctx.editorInfo, start[0], start[1]);
723
+ };
355
724
 
356
- if (key === ",") {
357
- if (lastCharSearch) {
358
- const opposite = { f: "F", F: "f", t: "T", T: "t" };
359
- const reverseDir = opposite[lastCharSearch.direction];
360
- const pos = charSearchPos(
361
- reverseDir,
362
- lineText,
363
- char,
364
- lastCharSearch.target,
365
- count,
366
- );
367
- if (pos !== -1) {
368
- desiredColumn = null;
369
- return { line, char: pos };
370
- }
371
- }
372
- return { line, char };
373
- }
725
+ commands["visual-line"]["~"] = (ctx) => {
726
+ const [start, end] = getVisualSelection(
727
+ "line",
728
+ state.visualAnchor,
729
+ state.visualCursor,
730
+ ctx.rep,
731
+ );
732
+ const text = getTextInRange(ctx.rep, start, end);
733
+ let toggled = "";
734
+ for (let i = 0; i < text.length; i++) {
735
+ const ch = text[i];
736
+ toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
737
+ }
738
+ replaceRange(ctx.editorInfo, start, end, toggled);
739
+ state.mode = "normal";
740
+ moveBlockCursor(ctx.editorInfo, start[0], start[1]);
741
+ };
374
742
 
375
- if (key === "f" || key === "F" || key === "t" || key === "T") {
376
- pendingKey = key;
377
- return "pending";
378
- }
743
+ // --- Miscellaneous ---
379
744
 
380
- if (key === "'" || key === "`") {
381
- pendingKey = key;
382
- return "pending";
383
- }
745
+ commands.normal["u"] = ({ editorInfo }) => {
746
+ editorInfo.ace_doUndoRedo("undo");
747
+ };
384
748
 
385
- if (key === "g") {
386
- pendingKey = "g";
387
- return "pending";
749
+ commands.normal["."] = (ctx) => {
750
+ if (!state.lastCommand) return;
751
+ const { key, count, param } = state.lastCommand;
752
+ if (param !== null && parameterized[key]) {
753
+ parameterized[key](param, ctx);
754
+ } else if (commands[state.mode] && commands[state.mode][key]) {
755
+ const newCtx = { ...ctx, count };
756
+ commands[state.mode][key](newCtx);
388
757
  }
758
+ };
389
759
 
390
- if (key === "%") {
391
- const pos = matchingBracketPos(rep, line, char);
392
- if (pos) {
393
- desiredColumn = null;
394
- return { line: pos.line, char: pos.char };
395
- }
396
- return { line, char };
397
- }
760
+ // --- Mode transitions ---
398
761
 
399
- if (key === "H") {
400
- desiredColumn = null;
401
- const { top } = getVisibleLineRange(rep);
402
- const targetLine = clampLine(top + count - 1, rep);
403
- const targetText = getLineText(rep, targetLine);
404
- return { line: targetLine, char: firstNonBlank(targetText) };
405
- }
762
+ commands.normal["i"] = ({ editorInfo, line, char }) => {
763
+ clearEmptyLineCursor();
764
+ moveCursor(editorInfo, line, char);
765
+ state.mode = "insert";
766
+ };
406
767
 
407
- if (key === "M") {
408
- desiredColumn = null;
409
- const { mid } = getVisibleLineRange(rep);
410
- const targetText = getLineText(rep, mid);
411
- return { line: mid, char: firstNonBlank(targetText) };
412
- }
768
+ commands.normal["a"] = ({ editorInfo, line, char, lineText }) => {
769
+ clearEmptyLineCursor();
770
+ moveCursor(editorInfo, line, Math.min(char + 1, lineText.length));
771
+ state.mode = "insert";
772
+ };
413
773
 
414
- if (key === "L") {
415
- desiredColumn = null;
416
- const { bottom } = getVisibleLineRange(rep);
417
- const targetLine = clampLine(bottom - count + 1, rep);
418
- const targetText = getLineText(rep, targetLine);
419
- return { line: targetLine, char: firstNonBlank(targetText) };
420
- }
774
+ commands.normal["A"] = ({ editorInfo, line, lineText }) => {
775
+ clearEmptyLineCursor();
776
+ moveCursor(editorInfo, line, lineText.length);
777
+ state.mode = "insert";
778
+ };
421
779
 
422
- return null;
780
+ commands.normal["I"] = ({ editorInfo, line, lineText }) => {
781
+ clearEmptyLineCursor();
782
+ moveCursor(editorInfo, line, firstNonBlank(lineText));
783
+ state.mode = "insert";
423
784
  };
424
785
 
425
- // --- Apply motion (mode-aware cursor placement) ---
786
+ commands["visual-char"]["i"] = () => {
787
+ state.pendingKey = "i";
788
+ };
426
789
 
427
- const applyMotion = (editorInfo, rep, newLine, newChar) => {
428
- if (visualMode !== null) {
429
- visualCursor = [newLine, newChar];
430
- updateVisualSelection(editorInfo, rep);
431
- } else {
432
- moveBlockCursor(editorInfo, newLine, newChar);
433
- }
790
+ commands["visual-char"]["a"] = () => {
791
+ state.pendingKey = "a";
792
+ };
434
793
 
435
- if (editorDoc) {
436
- const lineDiv = editorDoc.body.querySelectorAll("div")[newLine];
437
- if (lineDiv) lineDiv.scrollIntoView({ block: "nearest" });
438
- }
794
+ commands["visual-line"]["i"] = () => {
795
+ state.pendingKey = "i";
439
796
  };
440
797
 
441
- // --- Line deletion helper ---
798
+ commands["visual-line"]["a"] = () => {
799
+ state.pendingKey = "a";
800
+ };
442
801
 
443
- const deleteLines = (editorInfo, rep, topLine, bottomLine) => {
444
- const totalLines = rep.lines.length();
445
- if (bottomLine === totalLines - 1 && topLine > 0) {
446
- const prevLineLen = getLineText(rep, topLine - 1).length;
447
- replaceRange(
448
- editorInfo,
449
- [topLine - 1, prevLineLen],
450
- [bottomLine, getLineText(rep, bottomLine).length],
451
- "",
452
- );
453
- return topLine - 1;
454
- }
455
- if (bottomLine < totalLines - 1) {
456
- replaceRange(editorInfo, [topLine, 0], [bottomLine + 1, 0], "");
457
- return topLine;
458
- }
802
+ commands.normal["o"] = ({ editorInfo, line, lineText }) => {
803
+ clearEmptyLineCursor();
459
804
  replaceRange(
460
805
  editorInfo,
461
- [0, 0],
462
- [bottomLine, getLineText(rep, bottomLine).length],
463
- "",
806
+ [line, lineText.length],
807
+ [line, lineText.length],
808
+ "\n",
464
809
  );
465
- return 0;
810
+ moveCursor(editorInfo, line + 1, 0);
811
+ state.mode = "insert";
466
812
  };
467
813
 
468
- // --- Operator application ---
469
-
470
- const applyCharOperator = (operator, start, end, editorInfo, rep) => {
471
- if (start[0] === end[0]) {
472
- const lineText = getLineText(rep, start[0]);
473
- setRegister(lineText.slice(start[1], end[1]));
474
- } else {
475
- setRegister(getTextInRange(rep, start, end));
476
- }
477
- if (operator === "y") {
478
- moveBlockCursor(editorInfo, start[0], start[1]);
479
- return;
480
- }
481
- replaceRange(editorInfo, start, end, "");
482
- if (operator === "c") {
483
- moveCursor(editorInfo, start[0], start[1]);
484
- setInsertMode(true);
485
- } else {
486
- const newLineText = getLineText(rep, start[0]);
487
- moveBlockCursor(editorInfo, start[0], clampChar(start[1], newLineText));
488
- }
489
- };
490
-
491
- const applyLineOperator = (
492
- operator,
493
- topLine,
494
- bottomLine,
495
- editorInfo,
496
- rep,
497
- char,
498
- ) => {
499
- const lines = [];
500
- for (let i = topLine; i <= bottomLine; i++) {
501
- lines.push(getLineText(rep, i));
502
- }
503
- setRegister(lines);
504
- if (operator === "y") {
505
- moveBlockCursor(editorInfo, topLine, 0);
506
- return;
507
- }
508
- if (operator === "c") {
509
- for (let i = topLine; i <= bottomLine; i++) {
510
- const text = getLineText(rep, i);
511
- replaceRange(editorInfo, [topLine, 0], [topLine, text.length], "");
512
- }
513
- moveCursor(editorInfo, topLine, 0);
514
- setInsertMode(true);
515
- return;
516
- }
517
- const cursorLine = deleteLines(editorInfo, rep, topLine, bottomLine);
518
- const newLineText = getLineText(rep, cursorLine);
519
- moveBlockCursor(editorInfo, cursorLine, clampChar(char, newLineText));
814
+ commands.normal["O"] = ({ editorInfo, line }) => {
815
+ clearEmptyLineCursor();
816
+ replaceRange(editorInfo, [line, 0], [line, 0], "\n");
817
+ moveCursor(editorInfo, line, 0);
818
+ state.mode = "insert";
520
819
  };
521
820
 
522
- // --- Unified key handler ---
821
+ // --- More normal mode commands ---
523
822
 
524
- const handleKey = (rep, editorInfo, key) => {
525
- const inVisual = visualMode !== null;
526
- const line = inVisual ? visualCursor[0] : rep.selStart[0];
527
- const char = inVisual ? visualCursor[1] : rep.selStart[1];
528
- const lineText = getLineText(rep, line);
529
-
530
- if (key >= "1" && key <= "9") {
531
- countBuffer += key;
532
- return true;
533
- }
534
- if (key === "0" && countBuffer !== "") {
535
- countBuffer += key;
536
- return true;
823
+ commands.normal["r"] = () => {
824
+ state.pendingKey = "r";
825
+ };
826
+ parameterized["r"] = (key, { editorInfo, line, char, lineText, count }) => {
827
+ if (lineText.length > 0) {
828
+ replaceRange(editorInfo, [line, char], [line, char + 1], key);
829
+ moveBlockCursor(editorInfo, line, char);
830
+ recordCommand("r", count, key);
537
831
  }
832
+ };
538
833
 
539
- consumeCount();
540
- const count = getCount();
541
-
542
- // --- Normal-only pending states: r + char, m + letter ---
543
-
544
- if (pendingKey === "r") {
545
- pendingKey = null;
546
- if (lineText.length > 0) {
547
- replaceRange(editorInfo, [line, char], [line, char + 1], key);
548
- moveBlockCursor(editorInfo, line, char);
549
- }
550
- return true;
551
- }
834
+ commands.normal["Y"] = ({ rep, line }) => {
835
+ setRegister([getLineText(rep, line)]);
836
+ };
552
837
 
553
- if (pendingKey === "m") {
554
- pendingKey = null;
555
- if (key >= "a" && key <= "z") {
556
- marks[key] = [line, char];
557
- }
558
- return true;
838
+ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
839
+ if (lineText.length > 0) {
840
+ const deleteCount = Math.min(count, lineText.length - char);
841
+ setRegister(lineText.slice(char, char + deleteCount));
842
+ replaceRange(editorInfo, [line, char], [line, char + deleteCount], "");
843
+ const newLineText = getLineText(rep, line);
844
+ moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
845
+ recordCommand("x", count);
559
846
  }
847
+ };
560
848
 
561
- // --- Operator-pending: resolve target ---
562
-
563
- if (pendingOperator !== null) {
564
- const op = pendingOperator;
565
-
566
- if (key === op) {
567
- pendingOperator = null;
568
- const lineCount = rep.lines.length();
569
- const opCount = Math.min(count, lineCount - line);
570
- const lastLine = line + opCount - 1;
571
- applyLineOperator(op, line, lastLine, editorInfo, rep, char);
572
- return true;
573
- }
574
-
575
- if (pendingKey === "i" || pendingKey === "a") {
576
- const type = pendingKey;
577
- pendingKey = null;
578
- pendingOperator = null;
579
- const range = resolveTextObject(key, type, line, lineText, char, rep);
580
- if (range) {
581
- applyCharOperator(
582
- op,
583
- [range.startLine, range.startChar],
584
- [range.endLine, range.endChar],
585
- editorInfo,
586
- rep,
587
- );
588
- }
589
- return true;
590
- }
591
-
592
- if (
593
- pendingKey === "f" ||
594
- pendingKey === "F" ||
595
- pendingKey === "t" ||
596
- pendingKey === "T"
597
- ) {
598
- const direction = pendingKey;
599
- pendingKey = null;
600
- pendingOperator = null;
601
- lastCharSearch = { direction, target: key };
602
- const pos = charSearchPos(direction, lineText, char, key, count);
603
- if (pos !== -1) {
604
- const range = charMotionRange(direction, char, pos);
605
- if (range) {
606
- applyCharOperator(
607
- op,
608
- [line, range.start],
609
- [line, range.end],
610
- editorInfo,
611
- rep,
612
- );
613
- }
614
- }
615
- return true;
616
- }
617
-
618
- if (key === "i" || key === "a") {
619
- pendingKey = key;
620
- return true;
621
- }
622
-
623
- if (key === "f" || key === "F" || key === "t" || key === "T") {
624
- pendingKey = key;
625
- return true;
626
- }
627
-
628
- if (key === "%") {
629
- pendingOperator = null;
630
- const matchPos = matchingBracketPos(rep, line, char);
631
- if (matchPos) {
632
- let start, end;
633
- if (
634
- matchPos.line > line ||
635
- (matchPos.line === line && matchPos.char > char)
636
- ) {
637
- start = [line, char];
638
- end = [matchPos.line, matchPos.char + 1];
639
- } else {
640
- start = [matchPos.line, matchPos.char];
641
- end = [line, char + 1];
642
- }
643
- applyCharOperator(op, start, end, editorInfo, rep);
644
- }
645
- return true;
646
- }
647
-
648
- pendingOperator = null;
649
- const range = motionRange(key, char, lineText, count);
650
- if (range && range.end > range.start) {
651
- applyCharOperator(
652
- op,
653
- [line, range.start],
654
- [line, range.end],
849
+ commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
850
+ if (state.register !== null) {
851
+ if (typeof state.register === "string") {
852
+ const insertPos = Math.min(char + 1, lineText.length);
853
+ const repeated = state.register.repeat(count);
854
+ replaceRange(editorInfo, [line, insertPos], [line, insertPos], repeated);
855
+ moveBlockCursor(editorInfo, line, insertPos);
856
+ } else {
857
+ const block = state.register.join("\n");
858
+ const parts = [];
859
+ for (let i = 0; i < count; i++) parts.push(block);
860
+ const insertText = "\n" + parts.join("\n");
861
+ replaceRange(
655
862
  editorInfo,
656
- rep,
863
+ [line, lineText.length],
864
+ [line, lineText.length],
865
+ insertText,
657
866
  );
867
+ moveBlockCursor(editorInfo, line + 1, 0);
658
868
  }
659
- return true;
869
+ recordCommand("p", count);
660
870
  }
871
+ };
661
872
 
662
- // --- Text object in visual mode (i/a + object key) ---
663
-
664
- if (inVisual && (pendingKey === "i" || pendingKey === "a")) {
665
- const type = pendingKey;
666
- pendingKey = null;
667
- const range = resolveTextObject(key, type, line, lineText, char, rep);
668
- if (range) {
669
- visualAnchor = [range.startLine, range.startChar];
670
- visualCursor = [range.endLine, range.endChar];
671
- setVisualMode("char");
672
- updateVisualSelection(editorInfo, rep);
873
+ commands.normal["P"] = ({ editorInfo, line, char, count }) => {
874
+ if (state.register !== null) {
875
+ if (typeof state.register === "string") {
876
+ const repeated = state.register.repeat(count);
877
+ replaceRange(editorInfo, [line, char], [line, char], repeated);
878
+ moveBlockCursor(editorInfo, line, char);
879
+ } else {
880
+ const block = state.register.join("\n");
881
+ const parts = [];
882
+ for (let i = 0; i < count; i++) parts.push(block);
883
+ const insertText = parts.join("\n") + "\n";
884
+ replaceRange(editorInfo, [line, 0], [line, 0], insertText);
885
+ moveBlockCursor(editorInfo, line, 0);
673
886
  }
674
- return true;
887
+ recordCommand("P", count);
675
888
  }
889
+ };
676
890
 
677
- // --- Motions (shared between normal and visual) ---
678
-
679
- const motion = resolveMotion(key, line, char, lineText, rep, count);
680
- if (motion === "pending") return true;
681
- if (motion) {
682
- applyMotion(editorInfo, rep, motion.line, motion.char);
683
- return true;
891
+ commands.normal["J"] = ({ editorInfo, rep, line, lineText, count }) => {
892
+ const lineCount = rep.lines.length();
893
+ const joins = Math.min(count, lineCount - 1 - line);
894
+ let cursorChar = lineText.length;
895
+ for (let i = 0; i < joins; i++) {
896
+ const curLineText = getLineText(rep, line);
897
+ const nextLineText = getLineText(rep, line + 1);
898
+ const trimmedNext = nextLineText.replace(/^\s+/, "");
899
+ const separator = curLineText.length === 0 ? "" : " ";
900
+ if (i === 0) cursorChar = curLineText.length;
901
+ replaceRange(
902
+ editorInfo,
903
+ [line, curLineText.length],
904
+ [line + 1, nextLineText.length],
905
+ separator + trimmedNext,
906
+ );
684
907
  }
908
+ moveBlockCursor(editorInfo, line, cursorChar);
909
+ recordCommand("J", count);
910
+ };
685
911
 
686
- // --- Operators (d/c/y) ---
687
-
688
- if (key === "d" || key === "c" || key === "y") {
689
- if (inVisual) {
690
- if (visualMode === "char") {
691
- const [start, end] = getVisualSelection(
692
- visualMode,
693
- visualAnchor,
694
- visualCursor,
695
- rep,
696
- );
697
- setVisualMode(null);
698
- applyCharOperator(key, start, end, editorInfo, rep);
699
- } else {
700
- const topLine = Math.min(visualAnchor[0], visualCursor[0]);
701
- const bottomLine = Math.max(visualAnchor[0], visualCursor[0]);
702
- setVisualMode(null);
703
- applyLineOperator(key, topLine, bottomLine, editorInfo, rep, 0);
704
- }
705
- return true;
912
+ commands.normal["~"] = ({ editorInfo, rep, line, char, lineText, count }) => {
913
+ if (lineText.length > 0) {
914
+ const toggleCount = Math.min(count, lineText.length - char);
915
+ const slice = lineText.slice(char, char + toggleCount);
916
+ let toggled = "";
917
+ for (let i = 0; i < slice.length; i++) {
918
+ const ch = slice[i];
919
+ toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
706
920
  }
707
- pendingOperator = key;
708
- return true;
921
+ replaceRange(editorInfo, [line, char], [line, char + toggleCount], toggled);
922
+ const newChar = Math.min(char + toggleCount, lineText.length - 1);
923
+ moveBlockCursor(editorInfo, line, newChar);
924
+ recordCommand("~", count);
709
925
  }
926
+ };
710
927
 
711
- // --- Visual-mode specific ---
928
+ commands.normal["D"] = ({ editorInfo, rep, line, char, lineText }) => {
929
+ setRegister(lineText.slice(char));
930
+ replaceRange(editorInfo, [line, char], [line, lineText.length], "");
931
+ const newLineText = getLineText(rep, line);
932
+ moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
933
+ recordCommand("D", 1);
934
+ };
712
935
 
713
- if (inVisual) {
714
- if (key === "i" || key === "a") {
715
- pendingKey = key;
716
- return true;
717
- }
936
+ commands.normal["C"] = ({ editorInfo, line, char, lineText }) => {
937
+ clearEmptyLineCursor();
938
+ setRegister(lineText.slice(char));
939
+ replaceRange(editorInfo, [line, char], [line, lineText.length], "");
940
+ moveCursor(editorInfo, line, char);
941
+ state.mode = "insert";
942
+ recordCommand("C", 1);
943
+ };
718
944
 
719
- if (key === "~") {
720
- const [start, end] = getVisualSelection(
721
- visualMode,
722
- visualAnchor,
723
- visualCursor,
724
- rep,
725
- );
726
- const text = getTextInRange(rep, start, end);
727
- let toggled = "";
728
- for (let i = 0; i < text.length; i++) {
729
- const ch = text[i];
730
- toggled +=
731
- ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
732
- }
733
- replaceRange(editorInfo, start, end, toggled);
734
- setVisualMode(null);
735
- moveBlockCursor(editorInfo, start[0], start[1]);
736
- return true;
737
- }
945
+ commands.normal["s"] = ({ editorInfo, rep, line, char, lineText, count }) => {
946
+ clearEmptyLineCursor();
947
+ setRegister(lineText.slice(char, char + 1));
948
+ replaceRange(
949
+ editorInfo,
950
+ [line, char],
951
+ [line, Math.min(char + count, lineText.length)],
952
+ "",
953
+ );
954
+ moveCursor(editorInfo, line, char);
955
+ state.mode = "insert";
956
+ recordCommand("s", count);
957
+ };
738
958
 
739
- pendingKey = null;
740
- return false;
741
- }
959
+ commands.normal["S"] = ({ editorInfo, line, lineText }) => {
960
+ clearEmptyLineCursor();
961
+ setRegister(lineText);
962
+ replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
963
+ moveCursor(editorInfo, line, 0);
964
+ state.mode = "insert";
965
+ recordCommand("S", 1);
966
+ };
742
967
 
743
- // --- Normal-mode only commands ---
968
+ // --- Search ---
744
969
 
745
- if (key === "Y") {
746
- setRegister([lineText]);
747
- return true;
748
- }
970
+ commands.normal["/"] = () => {
971
+ state.searchMode = true;
972
+ state.searchBuffer = "";
973
+ state.searchDirection = "/";
974
+ };
749
975
 
750
- if (key === "r") {
751
- if (lineText.length > 0) pendingKey = "r";
752
- return true;
753
- }
976
+ commands.normal["?"] = () => {
977
+ state.searchMode = true;
978
+ state.searchBuffer = "";
979
+ state.searchDirection = "?";
980
+ };
754
981
 
755
- if (key === "m") {
756
- pendingKey = "m";
757
- return true;
758
- }
982
+ commands.normal["n"] = (ctx) => {
983
+ if (!state.lastSearch) return;
984
+ const { pattern, direction } = state.lastSearch;
985
+ const searchFunc = direction === "/" ? searchForward : searchBackward;
986
+ const pos = searchFunc(ctx.rep, ctx.line, ctx.char + 1, pattern, ctx.count);
987
+ if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
988
+ };
759
989
 
760
- if (key === "x") {
761
- if (lineText.length > 0) {
762
- const deleteCount = Math.min(count, lineText.length - char);
763
- replaceRange(editorInfo, [line, char], [line, char + deleteCount], "");
764
- const newLineText = getLineText(rep, line);
765
- moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
766
- }
767
- return true;
768
- }
990
+ commands.normal["N"] = (ctx) => {
991
+ if (!state.lastSearch) return;
992
+ const { pattern, direction } = state.lastSearch;
993
+ const searchFunc = direction === "/" ? searchBackward : searchForward;
994
+ const pos = searchFunc(ctx.rep, ctx.line, ctx.char, pattern, ctx.count);
995
+ if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
996
+ };
769
997
 
770
- if (key === "o") {
771
- replaceRange(
772
- editorInfo,
773
- [line, lineText.length],
774
- [line, lineText.length],
775
- "\n",
776
- );
777
- moveCursor(editorInfo, line + 1, 0);
778
- setInsertMode(true);
779
- return true;
780
- }
998
+ // --- Dispatch ---
781
999
 
782
- if (key === "O") {
783
- replaceRange(editorInfo, [line, 0], [line, 0], "\n");
784
- moveCursor(editorInfo, line, 0);
785
- setInsertMode(true);
1000
+ const handleKey = (key, ctx) => {
1001
+ if (key >= "1" && key <= "9") {
1002
+ state.countBuffer += key;
786
1003
  return true;
787
1004
  }
788
-
789
- if (key === "u") {
790
- undo(editorInfo);
1005
+ if (key === "0" && state.countBuffer !== "") {
1006
+ state.countBuffer += key;
791
1007
  return true;
792
1008
  }
793
1009
 
794
- if (key === "p") {
795
- if (register !== null) {
796
- if (typeof register === "string") {
797
- const insertPos = Math.min(char + 1, lineText.length);
798
- const repeated = register.repeat(count);
799
- replaceRange(
800
- editorInfo,
801
- [line, insertPos],
802
- [line, insertPos],
803
- repeated,
804
- );
805
- moveBlockCursor(editorInfo, line, insertPos);
806
- } else {
807
- const block = register.join("\n");
808
- const parts = [];
809
- for (let i = 0; i < count; i++) parts.push(block);
810
- const insertText = "\n" + parts.join("\n");
811
- replaceRange(
812
- editorInfo,
813
- [line, lineText.length],
814
- [line, lineText.length],
815
- insertText,
816
- );
817
- moveBlockCursor(editorInfo, line + 1, 0);
818
- }
819
- }
820
- return true;
1010
+ if (state.countBuffer !== "") {
1011
+ state.pendingCount = parseInt(state.countBuffer, 10);
1012
+ state.countBuffer = "";
821
1013
  }
1014
+ ctx.count = state.pendingCount !== null ? state.pendingCount : 1;
1015
+ ctx.hasCount = state.pendingCount !== null;
822
1016
 
823
- if (key === "P") {
824
- if (register !== null) {
825
- if (typeof register === "string") {
826
- const repeated = register.repeat(count);
827
- replaceRange(editorInfo, [line, char], [line, char], repeated);
828
- moveBlockCursor(editorInfo, line, char);
829
- } else {
830
- const block = register.join("\n");
831
- const parts = [];
832
- for (let i = 0; i < count; i++) parts.push(block);
833
- const insertText = parts.join("\n") + "\n";
834
- replaceRange(editorInfo, [line, 0], [line, 0], insertText);
835
- moveBlockCursor(editorInfo, line, 0);
836
- }
837
- }
1017
+ if (state.pendingKey !== null && parameterized[state.pendingKey]) {
1018
+ const handler = parameterized[state.pendingKey];
1019
+ state.pendingKey = null;
1020
+ handler(key, ctx);
1021
+ state.pendingCount = null;
838
1022
  return true;
839
1023
  }
840
1024
 
841
- if (key === "J") {
842
- const lineCount = rep.lines.length();
843
- const joins = Math.min(count, lineCount - 1 - line);
844
- let cursorChar = lineText.length;
845
- for (let i = 0; i < joins; i++) {
846
- const curLineText = getLineText(rep, line);
847
- const nextLineText = getLineText(rep, line + 1);
848
- const trimmedNext = nextLineText.replace(/^\s+/, "");
849
- const separator = curLineText.length === 0 ? "" : " ";
850
- if (i === 0) cursorChar = curLineText.length;
851
- replaceRange(
852
- editorInfo,
853
- [line, curLineText.length],
854
- [line + 1, nextLineText.length],
855
- separator + trimmedNext,
856
- );
857
- }
858
- moveBlockCursor(editorInfo, line, cursorChar);
859
- return true;
860
- }
1025
+ const map = commands[state.mode];
1026
+ const seq = state.pendingKey !== null ? state.pendingKey + key : key;
861
1027
 
862
- if (key === "~") {
863
- if (lineText.length > 0) {
864
- const toggleCount = Math.min(count, lineText.length - char);
865
- const slice = lineText.slice(char, char + toggleCount);
866
- let toggled = "";
867
- for (let i = 0; i < slice.length; i++) {
868
- const ch = slice[i];
869
- toggled +=
870
- ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
871
- }
872
- replaceRange(
873
- editorInfo,
874
- [line, char],
875
- [line, char + toggleCount],
876
- toggled,
877
- );
878
- const newChar = Math.min(char + toggleCount, lineText.length - 1);
879
- moveBlockCursor(editorInfo, line, newChar);
880
- }
1028
+ if (map[seq]) {
1029
+ state.pendingKey = null;
1030
+ map[seq](ctx);
1031
+ if (state.pendingKey === null) state.pendingCount = null;
881
1032
  return true;
882
1033
  }
883
1034
 
884
- if (key === "D") {
885
- setRegister(lineText.slice(char));
886
- replaceRange(editorInfo, [line, char], [line, lineText.length], "");
887
- const newLineText = getLineText(rep, line);
888
- moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
1035
+ const isPrefix = Object.keys(map).some(
1036
+ (k) => k.startsWith(seq) && k.length > seq.length,
1037
+ );
1038
+ if (isPrefix) {
1039
+ state.pendingKey = seq;
889
1040
  return true;
890
1041
  }
891
1042
 
892
- if (key === "C") {
893
- setRegister(lineText.slice(char));
894
- replaceRange(editorInfo, [line, char], [line, lineText.length], "");
895
- moveCursor(editorInfo, line, char);
896
- setInsertMode(true);
1043
+ if (
1044
+ state.pendingKey &&
1045
+ (key === "i" || key === "a") &&
1046
+ Object.keys(map).some((k) => k.startsWith(state.pendingKey + key))
1047
+ ) {
1048
+ state.pendingKey = state.pendingKey + key;
897
1049
  return true;
898
1050
  }
899
1051
 
900
- if (key === "s") {
901
- setRegister(lineText.slice(char, char + 1));
902
- replaceRange(
903
- editorInfo,
904
- [line, char],
905
- [line, Math.min(char + count, lineText.length)],
906
- "",
1052
+ if (
1053
+ (state.mode === "visual-char" || state.mode === "visual-line") &&
1054
+ (state.pendingKey === "i" || state.pendingKey === "a")
1055
+ ) {
1056
+ const type = state.pendingKey;
1057
+ state.pendingKey = null;
1058
+ const range = resolveTextObject(
1059
+ key,
1060
+ type,
1061
+ ctx.line,
1062
+ ctx.lineText,
1063
+ ctx.char,
1064
+ ctx.rep,
907
1065
  );
908
- moveCursor(editorInfo, line, char);
909
- setInsertMode(true);
910
- return true;
911
- }
912
-
913
- if (key === "S") {
914
- setRegister(lineText);
915
- replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
916
- moveCursor(editorInfo, line, 0);
917
- setInsertMode(true);
1066
+ if (range) {
1067
+ state.visualAnchor = [range.startLine, range.startChar];
1068
+ state.visualCursor = [range.endLine, range.endChar];
1069
+ updateVisualSelection(ctx.editorInfo, ctx.rep);
1070
+ }
918
1071
  return true;
919
1072
  }
920
1073
 
921
- pendingKey = null;
922
- return false;
1074
+ state.pendingKey = null;
1075
+ state.pendingCount = null;
1076
+ return true;
923
1077
  };
924
1078
 
925
- // --- Exports ---
1079
+ // --- Etherpad hooks ---
926
1080
 
927
1081
  exports.aceEditorCSS = () => ["ep_vim/static/css/vim.css"];
928
1082
 
@@ -942,7 +1096,7 @@ exports.postAceInit = (_hookName, { ace }) => {
942
1096
  ace.callWithAce((aceTop) => {
943
1097
  const rep = aceTop.ace_getRep();
944
1098
  if (rep && rep.selStart) {
945
- currentRep = rep;
1099
+ state.currentRep = rep;
946
1100
  selectRange(aceTop, rep.selStart, [rep.selStart[0], rep.selStart[1] + 1]);
947
1101
  }
948
1102
  });
@@ -951,102 +1105,82 @@ exports.postAceInit = (_hookName, { ace }) => {
951
1105
  exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
952
1106
  if (!vimEnabled) return false;
953
1107
  if (evt.type !== "keydown") return false;
954
- currentRep = rep;
955
- if (!editorDoc) {
956
- editorDoc = evt.target.ownerDocument;
957
- setInsertMode(insertMode);
958
- }
1108
+
1109
+ const isBrowserShortcut =
1110
+ (evt.ctrlKey || evt.metaKey) &&
1111
+ (evt.key === "x" || evt.key === "c" || evt.key === "v" || evt.key === "r");
1112
+ if (isBrowserShortcut) return false;
1113
+
1114
+ state.currentRep = rep;
1115
+ if (!state.editorDoc) state.editorDoc = evt.target.ownerDocument;
959
1116
 
960
1117
  if (evt.key === "Escape") {
961
- if (insertMode) {
962
- setInsertMode(false);
963
- const [line, char] = rep.selStart;
964
- moveBlockCursor(editorInfo, line, Math.max(0, char - 1));
965
- }
966
- if (visualMode !== null) {
967
- const [vLine, vChar] = visualCursor;
968
- setVisualMode(null);
1118
+ state.desiredColumn = null;
1119
+ if (state.mode === "visual-line") {
1120
+ const line = Math.min(state.visualAnchor[0], state.visualCursor[0]);
1121
+ state.mode = "normal";
1122
+ moveBlockCursor(editorInfo, line, 0);
1123
+ } else if (state.mode === "visual-char") {
1124
+ const [vLine, vChar] = state.visualCursor;
1125
+ state.mode = "normal";
969
1126
  moveBlockCursor(editorInfo, vLine, vChar);
1127
+ } else if (state.mode === "insert") {
1128
+ state.mode = "normal";
1129
+ const [curLine, curChar] = rep.selStart;
1130
+ moveBlockCursor(editorInfo, curLine, Math.max(0, curChar - 1));
1131
+ } else {
1132
+ state.mode = "normal";
1133
+ const [curLine, curChar] = rep.selStart;
1134
+ moveBlockCursor(editorInfo, curLine, curChar);
970
1135
  }
971
- countBuffer = "";
972
- pendingKey = null;
973
- pendingOperator = null;
974
- pendingCount = null;
975
- desiredColumn = null;
1136
+ state.pendingKey = null;
1137
+ state.pendingCount = null;
1138
+ state.countBuffer = "";
976
1139
  evt.preventDefault();
977
1140
  return true;
978
1141
  }
979
1142
 
980
- if (insertMode) return false;
981
-
982
- if (pendingKey !== null || pendingOperator !== null) {
983
- const handled = handleKey(rep, editorInfo, evt.key);
984
- evt.preventDefault();
985
- return handled || true;
986
- }
987
-
988
- if (visualMode === null) {
989
- if (evt.key === "i") {
990
- const [line, char] = rep.selStart;
991
- desiredColumn = null;
992
- moveCursor(editorInfo, line, char);
993
- setInsertMode(true);
994
- evt.preventDefault();
995
- return true;
996
- }
997
-
998
- if (evt.key === "a") {
999
- const [line, char] = rep.selStart;
1000
- const lineText = getLineText(rep, line);
1001
- desiredColumn = null;
1002
- moveCursor(editorInfo, line, Math.min(char + 1, lineText.length));
1003
- setInsertMode(true);
1004
- evt.preventDefault();
1005
- return true;
1006
- }
1143
+ if (state.mode === "insert") return false;
1007
1144
 
1008
- if (evt.key === "A") {
1009
- const [line] = rep.selStart;
1010
- const lineText = getLineText(rep, line);
1011
- desiredColumn = null;
1012
- moveCursor(editorInfo, line, lineText.length);
1013
- setInsertMode(true);
1145
+ if (state.searchMode) {
1146
+ if (evt.key === "Enter") {
1147
+ state.searchMode = false;
1148
+ const pattern = state.searchBuffer;
1149
+ state.lastSearch = { pattern, direction: state.searchDirection };
1150
+ const [curLine, curChar] = rep.selStart;
1151
+ const searchFunc =
1152
+ state.searchDirection === "/" ? searchForward : searchBackward;
1153
+ const pos = searchFunc(rep, curLine, curChar + 1, pattern);
1154
+ if (pos) moveBlockCursor(editorInfo, pos[0], pos[1]);
1155
+ state.searchBuffer = "";
1014
1156
  evt.preventDefault();
1015
1157
  return true;
1016
- }
1017
-
1018
- if (evt.key === "I") {
1019
- const [line] = rep.selStart;
1020
- const lineText = getLineText(rep, line);
1021
- desiredColumn = null;
1022
- moveCursor(editorInfo, line, firstNonBlank(lineText));
1023
- setInsertMode(true);
1158
+ } else if (evt.key === "Escape") {
1159
+ state.searchMode = false;
1160
+ state.searchBuffer = "";
1024
1161
  evt.preventDefault();
1025
1162
  return true;
1026
- }
1027
-
1028
- if (evt.key === "V") {
1029
- const [line] = rep.selStart;
1030
- visualAnchor = [line, 0];
1031
- visualCursor = [line, 0];
1032
- setVisualMode("line");
1033
- updateVisualSelection(editorInfo, rep);
1034
- evt.preventDefault();
1035
- return true;
1036
- }
1037
-
1038
- if (evt.key === "v") {
1039
- const [line, char] = rep.selStart;
1040
- visualAnchor = [line, char];
1041
- visualCursor = [line, char];
1042
- setVisualMode("char");
1043
- updateVisualSelection(editorInfo, rep);
1163
+ } else if (evt.key.length === 1 && !evt.ctrlKey && !evt.metaKey) {
1164
+ state.searchBuffer += evt.key;
1044
1165
  evt.preventDefault();
1045
1166
  return true;
1046
1167
  }
1168
+ return false;
1047
1169
  }
1048
1170
 
1049
- const handled = handleKey(rep, editorInfo, evt.key);
1050
- evt.preventDefault();
1051
- return handled || true;
1171
+ const [line, char] =
1172
+ state.mode === "visual-line" || state.mode === "visual-char"
1173
+ ? state.visualCursor
1174
+ : rep.selStart;
1175
+ const lineText = rep.lines.atIndex(line).text;
1176
+ const ctx = { rep, editorInfo, line, char, lineText };
1177
+ const handled = handleKey(evt.key, ctx);
1178
+ if (handled) evt.preventDefault();
1179
+ return handled;
1052
1180
  };
1181
+
1182
+ // Exports for testing
1183
+ exports._state = state;
1184
+ exports._handleKey = handleKey;
1185
+ exports._commands = commands;
1186
+ exports._parameterized = parameterized;