ep_vim 0.12.0 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -19,7 +19,7 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
19
19
  - **Line operations** — `dd`, `cc`, `yy`, `D`, `J` (join), `Y` (yank line)
20
20
  - **Registers** — `"a`–`"z` named registers for yank/delete/put, `"_` blackhole register
21
21
  - **Put** — `p` / `P` with linewise and characterwise register handling
22
- - **Editing** — `i` `a` `A` `I` (insert/append), `x`, `r`, `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
22
+ - **Editing** — `i` `a` `A` `I` (insert/append), `x`, `X`, `r`, `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
23
23
  - **Marks** — `m{a-z}` to set, `'{a-z}` / `` `{a-z} `` to jump
24
24
  - **Search** — `/` and `?` forward/backward, `n`/`N` repeat, `*`/`#` search word under cursor
25
25
  - **Scrolling** — `zz`/`zt`/`zb` center/top/bottom, `Ctrl+d`/`Ctrl+u` half-page, `Ctrl+f`/`Ctrl+b` full-page (requires ctrl keys enabled)
@@ -30,8 +30,12 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
30
30
  - **Toggle** — toolbar button to enable/disable vim mode, persisted in localStorage; settings panel for system clipboard and ctrl key behavior
31
31
 
32
32
  ## Differences from vi
33
+ The following are not planned, but PRs are welcome.
33
34
 
34
- - **No command line, macros, or globals** - these are not planned, but PRs welcome.
35
+ - **No command line, macros, or globals**
36
+ - **No visual block mode**
37
+ - **No indentation operators** — `>>`, `<<`, and `>` / `<` in visual mode
38
+ - **No increment/decrement** — `Ctrl+a` and `Ctrl+x`
35
39
 
36
40
  ## Installation
37
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_vim",
3
- "version": "0.12.0",
3
+ "version": "0.12.3",
4
4
  "description": "Vim-mode plugin for Etherpad with modal editing, motions, and operators",
5
5
  "author": {
6
6
  "name": "Seth Rothschild",
@@ -25,7 +25,7 @@
25
25
  "ep_etherpad-lite": ">=1.8.6"
26
26
  },
27
27
  "scripts": {
28
- "test": "npm run format && node --test static/js/vim-core.test.js static/js/index.test.js",
28
+ "test": "npm run format && node --test static/js/vim-core.test.js static/js/motions.test.js static/js/operators.test.js static/js/insert.test.js static/js/visual.test.js static/js/paste_registers.test.js static/js/search.test.js static/js/misc.test.js",
29
29
  "format": "prettier -w static/js/*"
30
30
  },
31
31
  "engines": {
@@ -540,6 +540,8 @@ registerMotion("L", (ctx) => {
540
540
  };
541
541
  });
542
542
 
543
+ // --- Char search motions ---
544
+
543
545
  registerParamMotion("f", (key, ctx) => {
544
546
  const pos = charSearchPos("f", ctx.lineText, ctx.char, key, ctx.count);
545
547
  return pos !== -1 ? pos : null;
@@ -702,9 +704,9 @@ commands.normal["v"] = ({ editorInfo, rep, line, char }) => {
702
704
  updateVisualSelection(editorInfo, rep);
703
705
  };
704
706
 
705
- commands.normal["V"] = ({ editorInfo, rep, line }) => {
706
- state.visualAnchor = [line, 0];
707
- state.visualCursor = [line, 0];
707
+ commands.normal["V"] = ({ editorInfo, rep, line, char }) => {
708
+ state.visualAnchor = [line, char];
709
+ state.visualCursor = [line, char];
708
710
  state.mode = "visual-line";
709
711
  updateVisualSelection(editorInfo, rep);
710
712
  };
@@ -768,6 +770,32 @@ commands["visual-line"]["~"] = (ctx) => {
768
770
  moveBlockCursor(ctx.editorInfo, start[0], start[1]);
769
771
  };
770
772
 
773
+ commands["visual-char"]["i"] = () => {
774
+ state.pendingKey = "i";
775
+ };
776
+
777
+ commands["visual-char"]["a"] = () => {
778
+ state.pendingKey = "a";
779
+ };
780
+
781
+ commands["visual-line"]["i"] = () => {
782
+ state.pendingKey = "i";
783
+ };
784
+
785
+ commands["visual-line"]["a"] = () => {
786
+ state.pendingKey = "a";
787
+ };
788
+
789
+ const swapVisualEnds = ({ editorInfo, rep }) => {
790
+ const tmp = state.visualAnchor;
791
+ state.visualAnchor = state.visualCursor;
792
+ state.visualCursor = tmp;
793
+ updateVisualSelection(editorInfo, rep);
794
+ };
795
+
796
+ commands["visual-char"]["o"] = swapVisualEnds;
797
+ commands["visual-line"]["o"] = swapVisualEnds;
798
+
771
799
  commands.normal["gv"] = ({ editorInfo, rep }) => {
772
800
  if (!state.lastVisualSelection) return;
773
801
  const { anchor, cursor, mode } = state.lastVisualSelection;
@@ -777,24 +805,7 @@ commands.normal["gv"] = ({ editorInfo, rep }) => {
777
805
  updateVisualSelection(editorInfo, rep);
778
806
  };
779
807
 
780
- // --- Miscellaneous ---
781
-
782
- commands.normal["u"] = ({ editorInfo }) => {
783
- editorInfo.ace_doUndoRedo("undo");
784
- };
785
-
786
- commands.normal["."] = (ctx) => {
787
- if (!state.lastCommand) return;
788
- const { key, count, param } = state.lastCommand;
789
- if (param !== null && parameterized[key]) {
790
- parameterized[key](param, ctx);
791
- } else if (commands[state.mode] && commands[state.mode][key]) {
792
- const newCtx = { ...ctx, count };
793
- commands[state.mode][key](newCtx);
794
- }
795
- };
796
-
797
- // --- Mode transitions ---
808
+ // --- Insert mode entry ---
798
809
 
799
810
  commands.normal["i"] = ({ editorInfo, line, char }) => {
800
811
  clearEmptyLineCursor();
@@ -820,22 +831,6 @@ commands.normal["I"] = ({ editorInfo, line, lineText }) => {
820
831
  state.mode = "insert";
821
832
  };
822
833
 
823
- commands["visual-char"]["i"] = () => {
824
- state.pendingKey = "i";
825
- };
826
-
827
- commands["visual-char"]["a"] = () => {
828
- state.pendingKey = "a";
829
- };
830
-
831
- commands["visual-line"]["i"] = () => {
832
- state.pendingKey = "i";
833
- };
834
-
835
- commands["visual-line"]["a"] = () => {
836
- state.pendingKey = "a";
837
- };
838
-
839
834
  commands.normal["o"] = ({ editorInfo, line, lineText }) => {
840
835
  clearEmptyLineCursor();
841
836
  replaceRange(
@@ -855,7 +850,7 @@ commands.normal["O"] = ({ editorInfo, line }) => {
855
850
  state.mode = "insert";
856
851
  };
857
852
 
858
- // --- More normal mode commands ---
853
+ // --- Editing commands ---
859
854
 
860
855
  commands.normal["r"] = () => {
861
856
  state.pendingKey = "r";
@@ -884,6 +879,18 @@ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
884
879
  }
885
880
  };
886
881
 
882
+ commands.normal["X"] = ({ editorInfo, rep, line, char, lineText, count }) => {
883
+ if (char > 0) {
884
+ const deleteCount = Math.min(count, char);
885
+ const startChar = char - deleteCount;
886
+ setRegister(lineText.slice(startChar, char));
887
+ replaceRange(editorInfo, [line, startChar], [line, char], "");
888
+ const newLineText = getLineText(rep, line);
889
+ moveBlockCursor(editorInfo, line, clampChar(startChar, newLineText));
890
+ recordCommand("X", count);
891
+ }
892
+ };
893
+
887
894
  commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
888
895
  const reg = getActiveRegister();
889
896
  if (reg !== null) {
@@ -1005,6 +1012,27 @@ commands.normal["S"] = ({ editorInfo, line, lineText }) => {
1005
1012
  recordCommand("S", 1);
1006
1013
  };
1007
1014
 
1015
+ // --- Undo, redo, repeat ---
1016
+
1017
+ commands.normal["u"] = ({ editorInfo }) => {
1018
+ editorInfo.ace_doUndoRedo("undo");
1019
+ };
1020
+
1021
+ commands.normal["<C-r>"] = ({ editorInfo }) => {
1022
+ editorInfo.ace_doUndoRedo("redo");
1023
+ };
1024
+
1025
+ commands.normal["."] = (ctx) => {
1026
+ if (!state.lastCommand) return;
1027
+ const { key, count, param } = state.lastCommand;
1028
+ if (param !== null && parameterized[key]) {
1029
+ parameterized[key](param, ctx);
1030
+ } else if (commands[state.mode] && commands[state.mode][key]) {
1031
+ const newCtx = { ...ctx, count };
1032
+ commands[state.mode][key](newCtx);
1033
+ }
1034
+ };
1035
+
1008
1036
  // --- Search ---
1009
1037
 
1010
1038
  commands.normal["/"] = () => {
@@ -1060,6 +1088,8 @@ commands.normal["#"] = (ctx) => {
1060
1088
  if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
1061
1089
  };
1062
1090
 
1091
+ // --- Scroll ---
1092
+
1063
1093
  commands.normal["zz"] = ({ line }) => {
1064
1094
  if (!state.editorDoc) return;
1065
1095
  const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
@@ -1078,6 +1108,49 @@ commands.normal["zb"] = ({ line }) => {
1078
1108
  if (lineDiv) lineDiv.scrollIntoView({ block: "end" });
1079
1109
  };
1080
1110
 
1111
+ const halfPage = 15;
1112
+ const fullPage = halfPage * 2;
1113
+
1114
+ commands.normal["<C-d>"] = ({ editorInfo, rep, line, char, count }) => {
1115
+ const target = Math.min(line + halfPage * count, rep.lines.length() - 1);
1116
+ const targetLen = rep.lines.atIndex(target).text.length;
1117
+ moveBlockCursor(
1118
+ editorInfo,
1119
+ target,
1120
+ Math.min(char, Math.max(0, targetLen - 1)),
1121
+ );
1122
+ };
1123
+
1124
+ commands.normal["<C-u>"] = ({ editorInfo, rep, line, char, count }) => {
1125
+ const target = Math.max(line - halfPage * count, 0);
1126
+ const targetLen = rep.lines.atIndex(target).text.length;
1127
+ moveBlockCursor(
1128
+ editorInfo,
1129
+ target,
1130
+ Math.min(char, Math.max(0, targetLen - 1)),
1131
+ );
1132
+ };
1133
+
1134
+ commands.normal["<C-f>"] = ({ editorInfo, rep, line, char, count }) => {
1135
+ const target = Math.min(line + fullPage * count, rep.lines.length() - 1);
1136
+ const targetLen = rep.lines.atIndex(target).text.length;
1137
+ moveBlockCursor(
1138
+ editorInfo,
1139
+ target,
1140
+ Math.min(char, Math.max(0, targetLen - 1)),
1141
+ );
1142
+ };
1143
+
1144
+ commands.normal["<C-b>"] = ({ editorInfo, rep, line, char, count }) => {
1145
+ const target = Math.max(line - fullPage * count, 0);
1146
+ const targetLen = rep.lines.atIndex(target).text.length;
1147
+ moveBlockCursor(
1148
+ editorInfo,
1149
+ target,
1150
+ Math.min(char, Math.max(0, targetLen - 1)),
1151
+ );
1152
+ };
1153
+
1081
1154
  // --- Dispatch ---
1082
1155
 
1083
1156
  const handleKey = (key, ctx) => {
@@ -1239,9 +1312,9 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1239
1312
  cursor: state.visualCursor,
1240
1313
  mode: "visual-line",
1241
1314
  };
1242
- const line = Math.min(state.visualAnchor[0], state.visualCursor[0]);
1315
+ const [vLine, vChar] = state.visualCursor;
1243
1316
  state.mode = "normal";
1244
- moveBlockCursor(editorInfo, line, 0);
1317
+ moveBlockCursor(editorInfo, vLine, vChar);
1245
1318
  } else if (state.mode === "visual-char") {
1246
1319
  state.lastVisualSelection = {
1247
1320
  anchor: state.visualAnchor,
@@ -1303,71 +1376,9 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1303
1376
  const ctx = { rep, editorInfo, line, char, lineText };
1304
1377
 
1305
1378
  if (useCtrlKeys && evt.ctrlKey && state.mode === "normal") {
1306
- if (state.countBuffer !== "") {
1307
- state.pendingCount = parseInt(state.countBuffer, 10);
1308
- state.countBuffer = "";
1309
- }
1310
- const count = state.pendingCount !== null ? state.pendingCount : 1;
1311
-
1312
- if (evt.key === "r") {
1313
- editorInfo.ace_doUndoRedo("redo");
1314
- state.pendingCount = null;
1315
- state.pendingRegister = null;
1316
- evt.preventDefault();
1317
- return true;
1318
- }
1319
-
1320
- const halfPage = 15;
1321
- if (evt.key === "d") {
1322
- const target = Math.min(line + halfPage * count, rep.lines.length() - 1);
1323
- const targetLen = rep.lines.atIndex(target).text.length;
1324
- moveBlockCursor(
1325
- editorInfo,
1326
- target,
1327
- Math.min(char, Math.max(0, targetLen - 1)),
1328
- );
1329
- state.pendingCount = null;
1330
- state.pendingRegister = null;
1331
- evt.preventDefault();
1332
- return true;
1333
- }
1334
- if (evt.key === "u") {
1335
- const target = Math.max(line - halfPage * count, 0);
1336
- const targetLen = rep.lines.atIndex(target).text.length;
1337
- moveBlockCursor(
1338
- editorInfo,
1339
- target,
1340
- Math.min(char, Math.max(0, targetLen - 1)),
1341
- );
1342
- state.pendingCount = null;
1343
- state.pendingRegister = null;
1344
- evt.preventDefault();
1345
- return true;
1346
- }
1347
- const fullPage = halfPage * 2;
1348
- if (evt.key === "f") {
1349
- const target = Math.min(line + fullPage * count, rep.lines.length() - 1);
1350
- const targetLen = rep.lines.atIndex(target).text.length;
1351
- moveBlockCursor(
1352
- editorInfo,
1353
- target,
1354
- Math.min(char, Math.max(0, targetLen - 1)),
1355
- );
1356
- state.pendingCount = null;
1357
- state.pendingRegister = null;
1358
- evt.preventDefault();
1359
- return true;
1360
- }
1361
- if (evt.key === "b") {
1362
- const target = Math.max(line - fullPage * count, 0);
1363
- const targetLen = rep.lines.atIndex(target).text.length;
1364
- moveBlockCursor(
1365
- editorInfo,
1366
- target,
1367
- Math.min(char, Math.max(0, targetLen - 1)),
1368
- );
1369
- state.pendingCount = null;
1370
- state.pendingRegister = null;
1379
+ const ctrlKey = "<C-" + evt.key + ">";
1380
+ if (commands.normal[ctrlKey]) {
1381
+ handleKey(ctrlKey, ctx);
1371
1382
  evt.preventDefault();
1372
1383
  return true;
1373
1384
  }
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+
3
+ const { describe, it, beforeEach } = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+
6
+ // Mock navigator for clipboard operations
7
+ global.navigator = {
8
+ clipboard: {
9
+ writeText: () => Promise.resolve(),
10
+ },
11
+ };
12
+
13
+ const {
14
+ _state: state,
15
+ _handleKey: handleKey,
16
+ _commands: commands,
17
+ _parameterized: parameterized,
18
+ _setVimEnabled: setVimEnabled,
19
+ _setUseCtrlKeys: setUseCtrlKeys,
20
+ aceKeyEvent,
21
+ } = require("./index.js");
22
+
23
+ const makeRep = (lines) => ({
24
+ lines: {
25
+ length: () => lines.length,
26
+ atIndex: (n) => ({ text: lines[n] }),
27
+ },
28
+ });
29
+
30
+ const makeMockEditorInfo = () => {
31
+ const calls = [];
32
+ return {
33
+ editorInfo: {
34
+ ace_inCallStackIfNecessary: (_name, fn) => fn(),
35
+ ace_performSelectionChange: (start, end, _flag) => {
36
+ calls.push({ type: "select", start, end });
37
+ },
38
+ ace_updateBrowserSelectionFromRep: () => {},
39
+ ace_performDocumentReplaceRange: (start, end, newText) => {
40
+ calls.push({ type: "replace", start, end, newText });
41
+ },
42
+ },
43
+ calls,
44
+ };
45
+ };
46
+
47
+ // ---------------------------------------------------------------------------
48
+
49
+ const resetState = () => {
50
+ state.mode = "normal";
51
+ state.pendingKey = null;
52
+ state.pendingCount = null;
53
+ state.countBuffer = "";
54
+ state.register = null;
55
+ state.namedRegisters = {};
56
+ state.pendingRegister = null;
57
+ state.awaitingRegister = false;
58
+ state.marks = {};
59
+ state.lastCharSearch = null;
60
+ state.visualAnchor = null;
61
+ state.visualCursor = null;
62
+ state.editorDoc = null;
63
+ state.currentRep = null;
64
+ state.desiredColumn = null;
65
+ state.lastCommand = null;
66
+ state.searchMode = false;
67
+ state.searchBuffer = "";
68
+ state.searchDirection = null;
69
+ state.lastSearch = null;
70
+ state.lastVisualSelection = null;
71
+ };
72
+
73
+ describe("insert mode commands", () => {
74
+ beforeEach(() => {
75
+ state.mode = "normal";
76
+ state.pendingKey = null;
77
+ state.pendingCount = null;
78
+ state.countBuffer = "";
79
+ state.register = null;
80
+ state.marks = {};
81
+ state.lastCharSearch = null;
82
+ state.visualAnchor = null;
83
+ state.visualCursor = null;
84
+ state.editorDoc = null;
85
+ state.currentRep = null;
86
+ state.desiredColumn = null;
87
+ state.lastCommand = null;
88
+ state.searchMode = false;
89
+ state.searchBuffer = "";
90
+ state.searchDirection = null;
91
+ state.lastSearch = null;
92
+ });
93
+
94
+ it("i enters insert mode at cursor", () => {
95
+ const rep = makeRep(["hello"]);
96
+ const { editorInfo } = makeMockEditorInfo();
97
+
98
+ const ctx = { rep, editorInfo, line: 0, char: 2, lineText: "hello" };
99
+ commands.normal["i"](ctx);
100
+
101
+ assert.equal(state.mode, "insert");
102
+ });
103
+
104
+ it("a enters insert mode after cursor", () => {
105
+ const rep = makeRep(["hello"]);
106
+ const { editorInfo } = makeMockEditorInfo();
107
+
108
+ const ctx = { rep, editorInfo, line: 0, char: 2, lineText: "hello" };
109
+ commands.normal["a"](ctx);
110
+
111
+ assert.equal(state.mode, "insert");
112
+ });
113
+
114
+ it("A enters insert mode at end of line", () => {
115
+ const rep = makeRep(["hello"]);
116
+ const { editorInfo } = makeMockEditorInfo();
117
+
118
+ const ctx = { rep, editorInfo, line: 0, char: 2, lineText: "hello" };
119
+ commands.normal["A"](ctx);
120
+
121
+ assert.equal(state.mode, "insert");
122
+ });
123
+
124
+ it("I enters insert mode at first non-blank", () => {
125
+ const rep = makeRep([" hello"]);
126
+ const { editorInfo } = makeMockEditorInfo();
127
+
128
+ const ctx = { rep, editorInfo, line: 0, char: 2, lineText: " hello" };
129
+ commands.normal["I"](ctx);
130
+
131
+ assert.equal(state.mode, "insert");
132
+ });
133
+
134
+ it("o opens line below", () => {
135
+ const rep = makeRep(["hello"]);
136
+ const { editorInfo, calls } = makeMockEditorInfo();
137
+
138
+ const ctx = { rep, editorInfo, line: 0, char: 0, lineText: "hello" };
139
+ commands.normal["o"](ctx);
140
+
141
+ assert.equal(state.mode, "insert");
142
+ assert.equal(calls.length, 2);
143
+ });
144
+
145
+ it("O opens line above", () => {
146
+ const rep = makeRep(["hello"]);
147
+ const { editorInfo, calls } = makeMockEditorInfo();
148
+
149
+ const ctx = { rep, editorInfo, line: 0, char: 0, lineText: "hello" };
150
+ commands.normal["O"](ctx);
151
+
152
+ assert.equal(state.mode, "insert");
153
+ assert.equal(calls.length, 2);
154
+ });
155
+
156
+ it("s replaces character and enters insert", () => {
157
+ const rep = makeRep(["hello"]);
158
+ const { editorInfo } = makeMockEditorInfo();
159
+
160
+ const ctx = {
161
+ rep,
162
+ editorInfo,
163
+ line: 0,
164
+ char: 1,
165
+ lineText: "hello",
166
+ count: 1,
167
+ };
168
+ commands.normal["s"](ctx);
169
+
170
+ assert.equal(state.mode, "insert");
171
+ });
172
+
173
+ it("S replaces entire line and enters insert", () => {
174
+ const rep = makeRep(["hello"]);
175
+ const { editorInfo } = makeMockEditorInfo();
176
+
177
+ const ctx = { rep, editorInfo, line: 0, char: 2, lineText: "hello" };
178
+ commands.normal["S"](ctx);
179
+
180
+ assert.equal(state.mode, "insert");
181
+ });
182
+
183
+ it("C changes to end of line and enters insert", () => {
184
+ const rep = makeRep(["hello world"]);
185
+ const { editorInfo } = makeMockEditorInfo();
186
+
187
+ const ctx = {
188
+ rep,
189
+ editorInfo,
190
+ line: 0,
191
+ char: 6,
192
+ lineText: "hello world",
193
+ };
194
+ commands.normal["C"](ctx);
195
+
196
+ assert.equal(state.mode, "insert");
197
+ });
198
+ });