ep_vim 0.9.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.
@@ -20,38 +20,40 @@ const {
20
20
  getVisualSelection,
21
21
  paragraphTextRange,
22
22
  sentenceTextRange,
23
- getFullText,
24
- posToAbsolute,
25
- absoluteToPos,
26
23
  searchForward,
27
24
  searchBackward,
28
25
  } = require("./vim-core");
29
26
 
30
27
  // --- State ---
31
28
 
32
- let vimEnabled = localStorage.getItem("ep_vimEnabled") === "true";
33
- let mode = "normal";
34
- let pendingKey = null;
35
- let pendingCount = null;
36
- let countBuffer = "";
37
- let register = null;
38
- let marks = {};
39
- let lastCharSearch = null;
40
- let visualAnchor = null;
41
- let visualCursor = null;
42
- let editorDoc = null;
43
- let currentRep = null;
44
- let desiredColumn = null;
45
- let lastCommand = null;
46
- let searchMode = false;
47
- let searchBuffer = "";
48
- let searchDirection = null;
49
- let lastSearch = null;
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
+ };
50
52
 
51
53
  // --- Editor operations ---
52
54
 
53
55
  const setRegister = (value) => {
54
- register = value;
56
+ state.register = value;
55
57
  const text = Array.isArray(value) ? value.join("\n") + "\n" : value;
56
58
  if (navigator.clipboard) {
57
59
  navigator.clipboard.writeText(text).catch(() => {});
@@ -80,33 +82,37 @@ const selectRange = (editorInfo, start, end) => {
80
82
  };
81
83
 
82
84
  const updateVisualSelection = (editorInfo, rep) => {
83
- const vMode = mode === "visual-line" ? "line" : "char";
85
+ const vMode = state.mode === "visual-line" ? "line" : "char";
84
86
  const [start, end] = getVisualSelection(
85
87
  vMode,
86
- visualAnchor,
87
- visualCursor,
88
+ state.visualAnchor,
89
+ state.visualCursor,
88
90
  rep,
89
91
  );
90
- selectRange(editorInfo, start, end);
92
+ if (vMode === "char") {
93
+ selectRange(editorInfo, start, [end[0], end[1] + 1]);
94
+ } else {
95
+ selectRange(editorInfo, start, end);
96
+ }
91
97
  };
92
98
 
93
99
  const clearEmptyLineCursor = () => {
94
- if (!editorDoc) return;
95
- const old = editorDoc.querySelector(".vim-empty-line-cursor");
100
+ if (!state.editorDoc) return;
101
+ const old = state.editorDoc.querySelector(".vim-empty-line-cursor");
96
102
  if (old) old.classList.remove("vim-empty-line-cursor");
97
103
  };
98
104
 
99
105
  const scrollLineIntoView = (line) => {
100
- if (!editorDoc) return;
101
- const lineDiv = editorDoc.body.querySelectorAll("div")[line];
106
+ if (!state.editorDoc) return;
107
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
102
108
  if (lineDiv) lineDiv.scrollIntoView({ block: "nearest" });
103
109
  };
104
110
 
105
111
  const moveBlockCursor = (editorInfo, line, char) => {
106
112
  clearEmptyLineCursor();
107
- const lineText = currentRep ? getLineText(currentRep, line) : "";
108
- if (lineText.length === 0 && editorDoc) {
109
- const lineDiv = editorDoc.body.querySelectorAll("div")[line];
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];
110
116
  if (lineDiv) lineDiv.classList.add("vim-empty-line-cursor");
111
117
  selectRange(editorInfo, [line, 0], [line, 0]);
112
118
  } else {
@@ -116,7 +122,7 @@ const moveBlockCursor = (editorInfo, line, char) => {
116
122
  };
117
123
 
118
124
  const moveVisualCursor = (editorInfo, rep, line, char) => {
119
- visualCursor = [line, char];
125
+ state.visualCursor = [line, char];
120
126
  updateVisualSelection(editorInfo, rep);
121
127
  scrollLineIntoView(line);
122
128
  };
@@ -158,12 +164,13 @@ const applyLineOperator = (op, topLine, bottomLine, ctx) => {
158
164
  return;
159
165
  }
160
166
  if (op === "c") {
161
- for (let i = topLine; i <= bottomLine; i++) {
162
- const text = getLineText(rep, i);
163
- replaceRange(editorInfo, [topLine, 0], [topLine, text.length], "");
167
+ if (bottomLine > topLine) {
168
+ deleteLines(editorInfo, rep, topLine + 1, bottomLine);
164
169
  }
170
+ const text = getLineText(rep, topLine);
171
+ replaceRange(editorInfo, [topLine, 0], [topLine, text.length], "");
165
172
  moveCursor(editorInfo, topLine, 0);
166
- mode = "insert";
173
+ state.mode = "insert";
167
174
  return;
168
175
  }
169
176
  const cursorLine = deleteLines(editorInfo, rep, topLine, bottomLine);
@@ -186,7 +193,7 @@ const applyOperator = (op, start, end, ctx) => {
186
193
  replaceRange(editorInfo, s, e, "");
187
194
  if (op === "c") {
188
195
  moveCursor(editorInfo, s[0], s[1]);
189
- mode = "insert";
196
+ state.mode = "insert";
190
197
  } else {
191
198
  moveBlockCursor(editorInfo, s[0], s[1]);
192
199
  }
@@ -229,28 +236,36 @@ const resolveTextObject = (key, type, line, lineText, char, rep) => {
229
236
  };
230
237
 
231
238
  const recordCommand = (key, count, param = null) => {
232
- lastCommand = { key, count, param };
239
+ state.lastCommand = { key, count, param };
233
240
  };
234
241
 
235
- const registerMotion = (key, getEndPos, inclusive = false) => {
242
+ const registerMotion = (
243
+ key,
244
+ getEndPos,
245
+ inclusive = false,
246
+ keepDesiredColumn = false,
247
+ ) => {
236
248
  commands.normal[key] = (ctx) => {
237
- desiredColumn = null;
249
+ if (!keepDesiredColumn) state.desiredColumn = null;
238
250
  const pos = getEndPos(ctx);
239
- if (pos) moveBlockCursor(ctx.editorInfo, pos.line, pos.char);
251
+ if (pos) {
252
+ const lineText = getLineText(ctx.rep, pos.line);
253
+ moveBlockCursor(ctx.editorInfo, pos.line, clampChar(pos.char, lineText));
254
+ }
240
255
  };
241
256
  commands["visual-char"][key] = (ctx) => {
242
- desiredColumn = null;
257
+ if (!keepDesiredColumn) state.desiredColumn = null;
243
258
  const pos = getEndPos(ctx);
244
259
  if (pos) moveVisualCursor(ctx.editorInfo, ctx.rep, pos.line, pos.char);
245
260
  };
246
261
  commands["visual-line"][key] = (ctx) => {
247
- desiredColumn = null;
262
+ if (!keepDesiredColumn) state.desiredColumn = null;
248
263
  const pos = getEndPos(ctx);
249
264
  if (pos) moveVisualCursor(ctx.editorInfo, ctx.rep, pos.line, pos.char);
250
265
  };
251
266
  for (const op of OPERATORS) {
252
267
  commands.normal[op + key] = (ctx) => {
253
- desiredColumn = null;
268
+ state.desiredColumn = null;
254
269
  const pos = getEndPos(ctx);
255
270
  if (pos) {
256
271
  const endChar = inclusive ? pos.char + 1 : pos.char;
@@ -265,19 +280,19 @@ const parameterized = {};
265
280
 
266
281
  const registerParamMotion = (key, getEndChar) => {
267
282
  commands.normal[key] = () => {
268
- pendingKey = key;
283
+ state.pendingKey = key;
269
284
  };
270
285
  commands["visual-char"][key] = () => {
271
- pendingKey = key;
286
+ state.pendingKey = key;
272
287
  };
273
288
  commands["visual-line"][key] = () => {
274
- pendingKey = key;
289
+ state.pendingKey = key;
275
290
  };
276
291
  parameterized[key] = (argKey, ctx) => {
277
- lastCharSearch = { direction: key, target: argKey };
292
+ state.lastCharSearch = { direction: key, target: argKey };
278
293
  const pos = getEndChar(argKey, ctx);
279
294
  if (pos !== null) {
280
- if (mode.startsWith("visual")) {
295
+ if (state.mode.startsWith("visual")) {
281
296
  moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
282
297
  } else {
283
298
  moveBlockCursor(ctx.editorInfo, ctx.line, pos);
@@ -288,10 +303,10 @@ const registerParamMotion = (key, getEndChar) => {
288
303
  for (const op of OPERATORS) {
289
304
  const combo = op + key;
290
305
  commands.normal[combo] = () => {
291
- pendingKey = combo;
306
+ state.pendingKey = combo;
292
307
  };
293
308
  parameterized[combo] = (argKey, ctx) => {
294
- lastCharSearch = { direction: key, target: argKey };
309
+ state.lastCharSearch = { direction: key, target: argKey };
295
310
  const pos = getEndChar(argKey, ctx);
296
311
  if (pos !== null) {
297
312
  const range = charMotionRange(key, ctx.char, pos);
@@ -321,10 +336,13 @@ const registerTextObject = (obj, getRange) => {
321
336
 
322
337
  const getVisibleLineRange = (rep) => {
323
338
  const totalLines = rep.lines.length();
324
- if (!editorDoc) return { top: 0, bottom: totalLines - 1 };
325
- 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");
326
344
  const lineCount = Math.min(lineDivs.length, totalLines);
327
- const frameEl = editorDoc.defaultView.frameElement;
345
+ const frameEl = state.editorDoc.defaultView.frameElement;
328
346
  const iframeTop = frameEl ? frameEl.getBoundingClientRect().top : 0;
329
347
  const outerViewportHeight = window.parent ? window.parent.innerHeight : 600;
330
348
  let top = 0;
@@ -369,24 +387,40 @@ registerMotion("l", (ctx) => ({
369
387
  char: clampChar(ctx.char + ctx.count, ctx.lineText),
370
388
  }));
371
389
 
372
- registerMotion("j", (ctx) => {
373
- if (desiredColumn === null) desiredColumn = ctx.char;
374
- const newLine = clampLine(ctx.line + ctx.count, ctx.rep);
375
- const newLineText = getLineText(ctx.rep, newLine);
376
- return { line: newLine, char: clampChar(desiredColumn, newLineText) };
377
- });
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
+ );
378
404
 
379
- registerMotion("k", (ctx) => {
380
- if (desiredColumn === null) desiredColumn = ctx.char;
381
- const newLine = clampLine(ctx.line - ctx.count, ctx.rep);
382
- const newLineText = getLineText(ctx.rep, newLine);
383
- return { line: newLine, char: clampChar(desiredColumn, newLineText) };
384
- });
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
+ );
385
419
 
386
420
  registerMotion("w", (ctx) => {
387
421
  let pos = ctx.char;
388
422
  for (let i = 0; i < ctx.count; i++) pos = wordForward(ctx.lineText, pos);
389
- return { line: ctx.line, char: clampChar(pos, ctx.lineText) };
423
+ return { line: ctx.line, char: pos };
390
424
  });
391
425
 
392
426
  registerMotion("b", (ctx) => {
@@ -498,108 +532,65 @@ registerParamMotion("T", (key, ctx) => {
498
532
  return pos !== -1 ? pos : null;
499
533
  });
500
534
 
501
- commands.normal[";"] = (ctx) => {
502
- if (!lastCharSearch) return;
503
- const pos = charSearchPos(
504
- lastCharSearch.direction,
505
- ctx.lineText,
506
- ctx.char,
507
- lastCharSearch.target,
508
- ctx.count,
509
- );
510
- if (pos !== -1) moveBlockCursor(ctx.editorInfo, ctx.line, pos);
511
- };
512
-
513
- commands.normal[","] = (ctx) => {
514
- if (!lastCharSearch) return;
515
- const opposite = { f: "F", F: "f", t: "T", T: "t" };
516
- const dir = opposite[lastCharSearch.direction];
517
- const pos = charSearchPos(
518
- dir,
519
- ctx.lineText,
520
- ctx.char,
521
- lastCharSearch.target,
522
- ctx.count,
523
- );
524
- if (pos !== -1) moveBlockCursor(ctx.editorInfo, ctx.line, pos);
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;
525
556
  };
526
557
 
527
- commands["visual-char"][";"] = (ctx) => {
528
- if (!lastCharSearch) return;
529
- const pos = charSearchPos(
530
- lastCharSearch.direction,
531
- ctx.lineText,
532
- ctx.char,
533
- lastCharSearch.target,
534
- ctx.count,
535
- );
536
- if (pos !== -1) moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
537
- };
538
-
539
- commands["visual-char"][","] = (ctx) => {
540
- if (!lastCharSearch) return;
541
- const opposite = { f: "F", F: "f", t: "T", T: "t" };
542
- const dir = opposite[lastCharSearch.direction];
543
- const pos = charSearchPos(
544
- dir,
545
- ctx.lineText,
546
- ctx.char,
547
- lastCharSearch.target,
548
- ctx.count,
549
- );
550
- if (pos !== -1) moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
558
+ const sameDirection = (dir) => dir;
559
+ const oppositeDirection = {
560
+ f: "F",
561
+ F: "f",
562
+ t: "T",
563
+ T: "t",
551
564
  };
565
+ const reverseDirection = (dir) => oppositeDirection[dir];
552
566
 
553
- commands["visual-line"][";"] = (ctx) => {
554
- if (!lastCharSearch) return;
555
- const pos = charSearchPos(
556
- lastCharSearch.direction,
557
- ctx.lineText,
558
- ctx.char,
559
- lastCharSearch.target,
560
- ctx.count,
561
- );
562
- if (pos !== -1) moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
563
- };
564
-
565
- commands["visual-line"][","] = (ctx) => {
566
- if (!lastCharSearch) return;
567
- const opposite = { f: "F", F: "f", t: "T", T: "t" };
568
- const dir = opposite[lastCharSearch.direction];
569
- const pos = charSearchPos(
570
- dir,
571
- ctx.lineText,
572
- ctx.char,
573
- lastCharSearch.target,
574
- ctx.count,
575
- );
576
- if (pos !== -1) moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
577
- };
567
+ registerCharRepeat(";", sameDirection);
568
+ registerCharRepeat(",", reverseDirection);
578
569
 
579
570
  // --- Marks ---
580
571
 
581
572
  commands.normal["m"] = () => {
582
- pendingKey = "m";
573
+ state.pendingKey = "m";
583
574
  };
584
575
  parameterized["m"] = (key, ctx) => {
585
- if (key >= "a" && key <= "z") marks[key] = [ctx.line, ctx.char];
576
+ if (key >= "a" && key <= "z") state.marks[key] = [ctx.line, ctx.char];
586
577
  };
587
578
 
588
579
  commands.normal["'"] = () => {
589
- pendingKey = "'";
580
+ state.pendingKey = "'";
590
581
  };
591
582
  commands["visual-char"]["'"] = () => {
592
- pendingKey = "'";
583
+ state.pendingKey = "'";
593
584
  };
594
585
  commands["visual-line"]["'"] = () => {
595
- pendingKey = "'";
586
+ state.pendingKey = "'";
596
587
  };
597
588
  parameterized["'"] = (key, ctx) => {
598
- if (!marks[key]) return;
599
- const [markLine] = marks[key];
589
+ if (!state.marks[key]) return;
590
+ const [markLine] = state.marks[key];
600
591
  const targetText = getLineText(ctx.rep, markLine);
601
592
  const targetChar = firstNonBlank(targetText);
602
- if (mode.startsWith("visual")) {
593
+ if (state.mode.startsWith("visual")) {
603
594
  moveVisualCursor(ctx.editorInfo, ctx.rep, markLine, targetChar);
604
595
  } else {
605
596
  moveBlockCursor(ctx.editorInfo, markLine, targetChar);
@@ -607,18 +598,18 @@ parameterized["'"] = (key, ctx) => {
607
598
  };
608
599
 
609
600
  commands.normal["`"] = () => {
610
- pendingKey = "`";
601
+ state.pendingKey = "`";
611
602
  };
612
603
  commands["visual-char"]["`"] = () => {
613
- pendingKey = "`";
604
+ state.pendingKey = "`";
614
605
  };
615
606
  commands["visual-line"]["`"] = () => {
616
- pendingKey = "`";
607
+ state.pendingKey = "`";
617
608
  };
618
609
  parameterized["`"] = (key, ctx) => {
619
- if (!marks[key]) return;
620
- const [markLine, markChar] = marks[key];
621
- if (mode.startsWith("visual")) {
610
+ if (!state.marks[key]) return;
611
+ const [markLine, markChar] = state.marks[key];
612
+ if (state.mode.startsWith("visual")) {
622
613
  moveVisualCursor(ctx.editorInfo, ctx.rep, markLine, markChar);
623
614
  } else {
624
615
  moveBlockCursor(ctx.editorInfo, markLine, markChar);
@@ -677,24 +668,24 @@ for (const op of OPERATORS) {
677
668
  // --- Visual modes ---
678
669
 
679
670
  commands.normal["v"] = ({ editorInfo, rep, line, char }) => {
680
- visualAnchor = [line, char];
681
- visualCursor = [line, char];
682
- mode = "visual-char";
671
+ state.visualAnchor = [line, char];
672
+ state.visualCursor = [line, char];
673
+ state.mode = "visual-char";
683
674
  updateVisualSelection(editorInfo, rep);
684
675
  };
685
676
 
686
677
  commands.normal["V"] = ({ editorInfo, rep, line }) => {
687
- visualAnchor = [line, 0];
688
- visualCursor = [line, 0];
689
- mode = "visual-line";
678
+ state.visualAnchor = [line, 0];
679
+ state.visualCursor = [line, 0];
680
+ state.mode = "visual-line";
690
681
  updateVisualSelection(editorInfo, rep);
691
682
  };
692
683
 
693
684
  for (const op of OPERATORS) {
694
685
  commands["visual-line"][op] = (ctx) => {
695
- const topLine = Math.min(visualAnchor[0], visualCursor[0]);
696
- const bottomLine = Math.max(visualAnchor[0], visualCursor[0]);
697
- mode = "normal";
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";
698
689
  applyLineOperator(op, topLine, bottomLine, ctx);
699
690
  };
700
691
  }
@@ -703,38 +694,39 @@ for (const op of OPERATORS) {
703
694
  commands["visual-char"][op] = (ctx) => {
704
695
  const [start, end] = getVisualSelection(
705
696
  "char",
706
- visualAnchor,
707
- visualCursor,
697
+ state.visualAnchor,
698
+ state.visualCursor,
708
699
  ctx.rep,
709
700
  );
710
- mode = "normal";
711
- applyOperator(op, start, end, ctx);
701
+ state.mode = "normal";
702
+ applyOperator(op, start, [end[0], end[1] + 1], ctx);
712
703
  };
713
704
  }
714
705
 
715
706
  commands["visual-char"]["~"] = (ctx) => {
716
707
  const [start, end] = getVisualSelection(
717
708
  "char",
718
- visualAnchor,
719
- visualCursor,
709
+ state.visualAnchor,
710
+ state.visualCursor,
720
711
  ctx.rep,
721
712
  );
722
- const text = getTextInRange(ctx.rep, start, end);
713
+ const adjustedEnd = [end[0], end[1] + 1];
714
+ const text = getTextInRange(ctx.rep, start, adjustedEnd);
723
715
  let toggled = "";
724
716
  for (let i = 0; i < text.length; i++) {
725
717
  const ch = text[i];
726
718
  toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
727
719
  }
728
- replaceRange(ctx.editorInfo, start, end, toggled);
729
- mode = "normal";
720
+ replaceRange(ctx.editorInfo, start, adjustedEnd, toggled);
721
+ state.mode = "normal";
730
722
  moveBlockCursor(ctx.editorInfo, start[0], start[1]);
731
723
  };
732
724
 
733
725
  commands["visual-line"]["~"] = (ctx) => {
734
726
  const [start, end] = getVisualSelection(
735
727
  "line",
736
- visualAnchor,
737
- visualCursor,
728
+ state.visualAnchor,
729
+ state.visualCursor,
738
730
  ctx.rep,
739
731
  );
740
732
  const text = getTextInRange(ctx.rep, start, end);
@@ -744,7 +736,7 @@ commands["visual-line"]["~"] = (ctx) => {
744
736
  toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
745
737
  }
746
738
  replaceRange(ctx.editorInfo, start, end, toggled);
747
- mode = "normal";
739
+ state.mode = "normal";
748
740
  moveBlockCursor(ctx.editorInfo, start[0], start[1]);
749
741
  };
750
742
 
@@ -755,13 +747,13 @@ commands.normal["u"] = ({ editorInfo }) => {
755
747
  };
756
748
 
757
749
  commands.normal["."] = (ctx) => {
758
- if (!lastCommand) return;
759
- const { key, count, param } = lastCommand;
750
+ if (!state.lastCommand) return;
751
+ const { key, count, param } = state.lastCommand;
760
752
  if (param !== null && parameterized[key]) {
761
753
  parameterized[key](param, ctx);
762
- } else if (commands[mode] && commands[mode][key]) {
754
+ } else if (commands[state.mode] && commands[state.mode][key]) {
763
755
  const newCtx = { ...ctx, count };
764
- commands[mode][key](newCtx);
756
+ commands[state.mode][key](newCtx);
765
757
  }
766
758
  };
767
759
 
@@ -770,41 +762,41 @@ commands.normal["."] = (ctx) => {
770
762
  commands.normal["i"] = ({ editorInfo, line, char }) => {
771
763
  clearEmptyLineCursor();
772
764
  moveCursor(editorInfo, line, char);
773
- mode = "insert";
765
+ state.mode = "insert";
774
766
  };
775
767
 
776
768
  commands.normal["a"] = ({ editorInfo, line, char, lineText }) => {
777
769
  clearEmptyLineCursor();
778
770
  moveCursor(editorInfo, line, Math.min(char + 1, lineText.length));
779
- mode = "insert";
771
+ state.mode = "insert";
780
772
  };
781
773
 
782
774
  commands.normal["A"] = ({ editorInfo, line, lineText }) => {
783
775
  clearEmptyLineCursor();
784
776
  moveCursor(editorInfo, line, lineText.length);
785
- mode = "insert";
777
+ state.mode = "insert";
786
778
  };
787
779
 
788
780
  commands.normal["I"] = ({ editorInfo, line, lineText }) => {
789
781
  clearEmptyLineCursor();
790
782
  moveCursor(editorInfo, line, firstNonBlank(lineText));
791
- mode = "insert";
783
+ state.mode = "insert";
792
784
  };
793
785
 
794
786
  commands["visual-char"]["i"] = () => {
795
- pendingKey = "i";
787
+ state.pendingKey = "i";
796
788
  };
797
789
 
798
790
  commands["visual-char"]["a"] = () => {
799
- pendingKey = "a";
791
+ state.pendingKey = "a";
800
792
  };
801
793
 
802
794
  commands["visual-line"]["i"] = () => {
803
- pendingKey = "i";
795
+ state.pendingKey = "i";
804
796
  };
805
797
 
806
798
  commands["visual-line"]["a"] = () => {
807
- pendingKey = "a";
799
+ state.pendingKey = "a";
808
800
  };
809
801
 
810
802
  commands.normal["o"] = ({ editorInfo, line, lineText }) => {
@@ -816,20 +808,20 @@ commands.normal["o"] = ({ editorInfo, line, lineText }) => {
816
808
  "\n",
817
809
  );
818
810
  moveCursor(editorInfo, line + 1, 0);
819
- mode = "insert";
811
+ state.mode = "insert";
820
812
  };
821
813
 
822
814
  commands.normal["O"] = ({ editorInfo, line }) => {
823
815
  clearEmptyLineCursor();
824
816
  replaceRange(editorInfo, [line, 0], [line, 0], "\n");
825
817
  moveCursor(editorInfo, line, 0);
826
- mode = "insert";
818
+ state.mode = "insert";
827
819
  };
828
820
 
829
821
  // --- More normal mode commands ---
830
822
 
831
823
  commands.normal["r"] = () => {
832
- pendingKey = "r";
824
+ state.pendingKey = "r";
833
825
  };
834
826
  parameterized["r"] = (key, { editorInfo, line, char, lineText, count }) => {
835
827
  if (lineText.length > 0) {
@@ -855,14 +847,14 @@ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
855
847
  };
856
848
 
857
849
  commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
858
- if (register !== null) {
859
- if (typeof register === "string") {
850
+ if (state.register !== null) {
851
+ if (typeof state.register === "string") {
860
852
  const insertPos = Math.min(char + 1, lineText.length);
861
- const repeated = register.repeat(count);
853
+ const repeated = state.register.repeat(count);
862
854
  replaceRange(editorInfo, [line, insertPos], [line, insertPos], repeated);
863
855
  moveBlockCursor(editorInfo, line, insertPos);
864
856
  } else {
865
- const block = register.join("\n");
857
+ const block = state.register.join("\n");
866
858
  const parts = [];
867
859
  for (let i = 0; i < count; i++) parts.push(block);
868
860
  const insertText = "\n" + parts.join("\n");
@@ -879,13 +871,13 @@ commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
879
871
  };
880
872
 
881
873
  commands.normal["P"] = ({ editorInfo, line, char, count }) => {
882
- if (register !== null) {
883
- if (typeof register === "string") {
884
- const repeated = register.repeat(count);
874
+ if (state.register !== null) {
875
+ if (typeof state.register === "string") {
876
+ const repeated = state.register.repeat(count);
885
877
  replaceRange(editorInfo, [line, char], [line, char], repeated);
886
878
  moveBlockCursor(editorInfo, line, char);
887
879
  } else {
888
- const block = register.join("\n");
880
+ const block = state.register.join("\n");
889
881
  const parts = [];
890
882
  for (let i = 0; i < count; i++) parts.push(block);
891
883
  const insertText = parts.join("\n") + "\n";
@@ -946,7 +938,7 @@ commands.normal["C"] = ({ editorInfo, line, char, lineText }) => {
946
938
  setRegister(lineText.slice(char));
947
939
  replaceRange(editorInfo, [line, char], [line, lineText.length], "");
948
940
  moveCursor(editorInfo, line, char);
949
- mode = "insert";
941
+ state.mode = "insert";
950
942
  recordCommand("C", 1);
951
943
  };
952
944
 
@@ -960,7 +952,7 @@ commands.normal["s"] = ({ editorInfo, rep, line, char, lineText, count }) => {
960
952
  "",
961
953
  );
962
954
  moveCursor(editorInfo, line, char);
963
- mode = "insert";
955
+ state.mode = "insert";
964
956
  recordCommand("s", count);
965
957
  };
966
958
 
@@ -969,35 +961,35 @@ commands.normal["S"] = ({ editorInfo, line, lineText }) => {
969
961
  setRegister(lineText);
970
962
  replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
971
963
  moveCursor(editorInfo, line, 0);
972
- mode = "insert";
964
+ state.mode = "insert";
973
965
  recordCommand("S", 1);
974
966
  };
975
967
 
976
968
  // --- Search ---
977
969
 
978
970
  commands.normal["/"] = () => {
979
- searchMode = true;
980
- searchBuffer = "";
981
- searchDirection = "/";
971
+ state.searchMode = true;
972
+ state.searchBuffer = "";
973
+ state.searchDirection = "/";
982
974
  };
983
975
 
984
976
  commands.normal["?"] = () => {
985
- searchMode = true;
986
- searchBuffer = "";
987
- searchDirection = "?";
977
+ state.searchMode = true;
978
+ state.searchBuffer = "";
979
+ state.searchDirection = "?";
988
980
  };
989
981
 
990
982
  commands.normal["n"] = (ctx) => {
991
- if (!lastSearch) return;
992
- const { pattern, direction } = lastSearch;
983
+ if (!state.lastSearch) return;
984
+ const { pattern, direction } = state.lastSearch;
993
985
  const searchFunc = direction === "/" ? searchForward : searchBackward;
994
986
  const pos = searchFunc(ctx.rep, ctx.line, ctx.char + 1, pattern, ctx.count);
995
987
  if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
996
988
  };
997
989
 
998
990
  commands.normal["N"] = (ctx) => {
999
- if (!lastSearch) return;
1000
- const { pattern, direction } = lastSearch;
991
+ if (!state.lastSearch) return;
992
+ const { pattern, direction } = state.lastSearch;
1001
993
  const searchFunc = direction === "/" ? searchBackward : searchForward;
1002
994
  const pos = searchFunc(ctx.rep, ctx.line, ctx.char, pattern, ctx.count);
1003
995
  if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
@@ -1007,36 +999,36 @@ commands.normal["N"] = (ctx) => {
1007
999
 
1008
1000
  const handleKey = (key, ctx) => {
1009
1001
  if (key >= "1" && key <= "9") {
1010
- countBuffer += key;
1002
+ state.countBuffer += key;
1011
1003
  return true;
1012
1004
  }
1013
- if (key === "0" && countBuffer !== "") {
1014
- countBuffer += key;
1005
+ if (key === "0" && state.countBuffer !== "") {
1006
+ state.countBuffer += key;
1015
1007
  return true;
1016
1008
  }
1017
1009
 
1018
- if (countBuffer !== "") {
1019
- pendingCount = parseInt(countBuffer, 10);
1020
- countBuffer = "";
1010
+ if (state.countBuffer !== "") {
1011
+ state.pendingCount = parseInt(state.countBuffer, 10);
1012
+ state.countBuffer = "";
1021
1013
  }
1022
- ctx.count = pendingCount !== null ? pendingCount : 1;
1023
- ctx.hasCount = pendingCount !== null;
1014
+ ctx.count = state.pendingCount !== null ? state.pendingCount : 1;
1015
+ ctx.hasCount = state.pendingCount !== null;
1024
1016
 
1025
- if (pendingKey !== null && parameterized[pendingKey]) {
1026
- const handler = parameterized[pendingKey];
1027
- pendingKey = null;
1017
+ if (state.pendingKey !== null && parameterized[state.pendingKey]) {
1018
+ const handler = parameterized[state.pendingKey];
1019
+ state.pendingKey = null;
1028
1020
  handler(key, ctx);
1029
- pendingCount = null;
1021
+ state.pendingCount = null;
1030
1022
  return true;
1031
1023
  }
1032
1024
 
1033
- const map = commands[mode];
1034
- const seq = pendingKey !== null ? pendingKey + key : key;
1025
+ const map = commands[state.mode];
1026
+ const seq = state.pendingKey !== null ? state.pendingKey + key : key;
1035
1027
 
1036
1028
  if (map[seq]) {
1037
- pendingKey = null;
1029
+ state.pendingKey = null;
1038
1030
  map[seq](ctx);
1039
- if (pendingKey === null) pendingCount = null;
1031
+ if (state.pendingKey === null) state.pendingCount = null;
1040
1032
  return true;
1041
1033
  }
1042
1034
 
@@ -1044,25 +1036,25 @@ const handleKey = (key, ctx) => {
1044
1036
  (k) => k.startsWith(seq) && k.length > seq.length,
1045
1037
  );
1046
1038
  if (isPrefix) {
1047
- pendingKey = seq;
1039
+ state.pendingKey = seq;
1048
1040
  return true;
1049
1041
  }
1050
1042
 
1051
1043
  if (
1052
- pendingKey &&
1044
+ state.pendingKey &&
1053
1045
  (key === "i" || key === "a") &&
1054
- Object.keys(map).some((k) => k.startsWith(pendingKey + key))
1046
+ Object.keys(map).some((k) => k.startsWith(state.pendingKey + key))
1055
1047
  ) {
1056
- pendingKey = pendingKey + key;
1048
+ state.pendingKey = state.pendingKey + key;
1057
1049
  return true;
1058
1050
  }
1059
1051
 
1060
1052
  if (
1061
- (mode === "visual-char" || mode === "visual-line") &&
1062
- (pendingKey === "i" || pendingKey === "a")
1053
+ (state.mode === "visual-char" || state.mode === "visual-line") &&
1054
+ (state.pendingKey === "i" || state.pendingKey === "a")
1063
1055
  ) {
1064
- const type = pendingKey;
1065
- pendingKey = null;
1056
+ const type = state.pendingKey;
1057
+ state.pendingKey = null;
1066
1058
  const range = resolveTextObject(
1067
1059
  key,
1068
1060
  type,
@@ -1072,15 +1064,15 @@ const handleKey = (key, ctx) => {
1072
1064
  ctx.rep,
1073
1065
  );
1074
1066
  if (range) {
1075
- visualAnchor = [range.startLine, range.startChar];
1076
- visualCursor = [range.endLine, range.endChar];
1067
+ state.visualAnchor = [range.startLine, range.startChar];
1068
+ state.visualCursor = [range.endLine, range.endChar];
1077
1069
  updateVisualSelection(ctx.editorInfo, ctx.rep);
1078
1070
  }
1079
1071
  return true;
1080
1072
  }
1081
1073
 
1082
- pendingKey = null;
1083
- pendingCount = null;
1074
+ state.pendingKey = null;
1075
+ state.pendingCount = null;
1084
1076
  return true;
1085
1077
  };
1086
1078
 
@@ -1104,7 +1096,7 @@ exports.postAceInit = (_hookName, { ace }) => {
1104
1096
  ace.callWithAce((aceTop) => {
1105
1097
  const rep = aceTop.ace_getRep();
1106
1098
  if (rep && rep.selStart) {
1107
- currentRep = rep;
1099
+ state.currentRep = rep;
1108
1100
  selectRange(aceTop, rep.selStart, [rep.selStart[0], rep.selStart[1] + 1]);
1109
1101
  }
1110
1102
  });
@@ -1119,57 +1111,57 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1119
1111
  (evt.key === "x" || evt.key === "c" || evt.key === "v" || evt.key === "r");
1120
1112
  if (isBrowserShortcut) return false;
1121
1113
 
1122
- currentRep = rep;
1123
- if (!editorDoc) editorDoc = evt.target.ownerDocument;
1114
+ state.currentRep = rep;
1115
+ if (!state.editorDoc) state.editorDoc = evt.target.ownerDocument;
1124
1116
 
1125
1117
  if (evt.key === "Escape") {
1126
- desiredColumn = null;
1127
- if (mode === "visual-line") {
1128
- const line = Math.min(visualAnchor[0], visualCursor[0]);
1129
- mode = "normal";
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";
1130
1122
  moveBlockCursor(editorInfo, line, 0);
1131
- } else if (mode === "visual-char") {
1132
- const [vLine, vChar] = visualCursor;
1133
- mode = "normal";
1123
+ } else if (state.mode === "visual-char") {
1124
+ const [vLine, vChar] = state.visualCursor;
1125
+ state.mode = "normal";
1134
1126
  moveBlockCursor(editorInfo, vLine, vChar);
1135
- } else if (mode === "insert") {
1136
- mode = "normal";
1127
+ } else if (state.mode === "insert") {
1128
+ state.mode = "normal";
1137
1129
  const [curLine, curChar] = rep.selStart;
1138
1130
  moveBlockCursor(editorInfo, curLine, Math.max(0, curChar - 1));
1139
1131
  } else {
1140
- mode = "normal";
1132
+ state.mode = "normal";
1141
1133
  const [curLine, curChar] = rep.selStart;
1142
1134
  moveBlockCursor(editorInfo, curLine, curChar);
1143
1135
  }
1144
- pendingKey = null;
1145
- pendingCount = null;
1146
- countBuffer = "";
1136
+ state.pendingKey = null;
1137
+ state.pendingCount = null;
1138
+ state.countBuffer = "";
1147
1139
  evt.preventDefault();
1148
1140
  return true;
1149
1141
  }
1150
1142
 
1151
- if (mode === "insert") return false;
1143
+ if (state.mode === "insert") return false;
1152
1144
 
1153
- if (searchMode) {
1145
+ if (state.searchMode) {
1154
1146
  if (evt.key === "Enter") {
1155
- searchMode = false;
1156
- const pattern = searchBuffer;
1157
- lastSearch = { pattern, direction: searchDirection };
1147
+ state.searchMode = false;
1148
+ const pattern = state.searchBuffer;
1149
+ state.lastSearch = { pattern, direction: state.searchDirection };
1158
1150
  const [curLine, curChar] = rep.selStart;
1159
1151
  const searchFunc =
1160
- searchDirection === "/" ? searchForward : searchBackward;
1152
+ state.searchDirection === "/" ? searchForward : searchBackward;
1161
1153
  const pos = searchFunc(rep, curLine, curChar + 1, pattern);
1162
1154
  if (pos) moveBlockCursor(editorInfo, pos[0], pos[1]);
1163
- searchBuffer = "";
1155
+ state.searchBuffer = "";
1164
1156
  evt.preventDefault();
1165
1157
  return true;
1166
1158
  } else if (evt.key === "Escape") {
1167
- searchMode = false;
1168
- searchBuffer = "";
1159
+ state.searchMode = false;
1160
+ state.searchBuffer = "";
1169
1161
  evt.preventDefault();
1170
1162
  return true;
1171
1163
  } else if (evt.key.length === 1 && !evt.ctrlKey && !evt.metaKey) {
1172
- searchBuffer += evt.key;
1164
+ state.searchBuffer += evt.key;
1173
1165
  evt.preventDefault();
1174
1166
  return true;
1175
1167
  }
@@ -1177,8 +1169,8 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1177
1169
  }
1178
1170
 
1179
1171
  const [line, char] =
1180
- mode === "visual-line" || mode === "visual-char"
1181
- ? visualCursor
1172
+ state.mode === "visual-line" || state.mode === "visual-char"
1173
+ ? state.visualCursor
1182
1174
  : rep.selStart;
1183
1175
  const lineText = rep.lines.atIndex(line).text;
1184
1176
  const ctx = { rep, editorInfo, line, char, lineText };
@@ -1186,3 +1178,9 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1186
1178
  if (handled) evt.preventDefault();
1187
1179
  return handled;
1188
1180
  };
1181
+
1182
+ // Exports for testing
1183
+ exports._state = state;
1184
+ exports._handleKey = handleKey;
1185
+ exports._commands = commands;
1186
+ exports._parameterized = parameterized;