ep_vim 0.12.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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`, `R` (replace mode), `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.1",
3
+ "version": "0.13.0",
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": {
@@ -704,9 +704,9 @@ commands.normal["v"] = ({ editorInfo, rep, line, char }) => {
704
704
  updateVisualSelection(editorInfo, rep);
705
705
  };
706
706
 
707
- commands.normal["V"] = ({ editorInfo, rep, line }) => {
708
- state.visualAnchor = [line, 0];
709
- state.visualCursor = [line, 0];
707
+ commands.normal["V"] = ({ editorInfo, rep, line, char }) => {
708
+ state.visualAnchor = [line, char];
709
+ state.visualCursor = [line, char];
710
710
  state.mode = "visual-line";
711
711
  updateVisualSelection(editorInfo, rep);
712
712
  };
@@ -786,6 +786,16 @@ commands["visual-line"]["a"] = () => {
786
786
  state.pendingKey = "a";
787
787
  };
788
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
+
789
799
  commands.normal["gv"] = ({ editorInfo, rep }) => {
790
800
  if (!state.lastVisualSelection) return;
791
801
  const { anchor, cursor, mode } = state.lastVisualSelection;
@@ -840,6 +850,12 @@ commands.normal["O"] = ({ editorInfo, line }) => {
840
850
  state.mode = "insert";
841
851
  };
842
852
 
853
+ commands.normal["R"] = ({ editorInfo, line, char }) => {
854
+ clearEmptyLineCursor();
855
+ moveCursor(editorInfo, line, char);
856
+ state.mode = "replace";
857
+ };
858
+
843
859
  // --- Editing commands ---
844
860
 
845
861
  commands.normal["r"] = () => {
@@ -869,6 +885,18 @@ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
869
885
  }
870
886
  };
871
887
 
888
+ commands.normal["X"] = ({ editorInfo, rep, line, char, lineText, count }) => {
889
+ if (char > 0) {
890
+ const deleteCount = Math.min(count, char);
891
+ const startChar = char - deleteCount;
892
+ setRegister(lineText.slice(startChar, char));
893
+ replaceRange(editorInfo, [line, startChar], [line, char], "");
894
+ const newLineText = getLineText(rep, line);
895
+ moveBlockCursor(editorInfo, line, clampChar(startChar, newLineText));
896
+ recordCommand("X", count);
897
+ }
898
+ };
899
+
872
900
  commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
873
901
  const reg = getActiveRegister();
874
902
  if (reg !== null) {
@@ -1290,9 +1318,9 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1290
1318
  cursor: state.visualCursor,
1291
1319
  mode: "visual-line",
1292
1320
  };
1293
- const line = Math.min(state.visualAnchor[0], state.visualCursor[0]);
1321
+ const [vLine, vChar] = state.visualCursor;
1294
1322
  state.mode = "normal";
1295
- moveBlockCursor(editorInfo, line, 0);
1323
+ moveBlockCursor(editorInfo, vLine, vChar);
1296
1324
  } else if (state.mode === "visual-char") {
1297
1325
  state.lastVisualSelection = {
1298
1326
  anchor: state.visualAnchor,
@@ -1302,7 +1330,7 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1302
1330
  const [vLine, vChar] = state.visualCursor;
1303
1331
  state.mode = "normal";
1304
1332
  moveBlockCursor(editorInfo, vLine, vChar);
1305
- } else if (state.mode === "insert") {
1333
+ } else if (state.mode === "insert" || state.mode === "replace") {
1306
1334
  state.mode = "normal";
1307
1335
  const [curLine, curChar] = rep.selStart;
1308
1336
  moveBlockCursor(editorInfo, curLine, Math.max(0, curChar - 1));
@@ -1320,6 +1348,40 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1320
1348
 
1321
1349
  if (state.mode === "insert") return false;
1322
1350
 
1351
+ if (state.mode === "replace") {
1352
+ if (evt.key === "Backspace") {
1353
+ const [curLine, curChar] = rep.selStart;
1354
+ if (curChar > 0) {
1355
+ moveCursor(editorInfo, curLine, curChar - 1);
1356
+ }
1357
+ evt.preventDefault();
1358
+ return true;
1359
+ }
1360
+ if (evt.key.length === 1 && !evt.ctrlKey && !evt.metaKey) {
1361
+ const [curLine, curChar] = rep.selStart;
1362
+ const curLineText = rep.lines.atIndex(curLine).text;
1363
+ if (curChar < curLineText.length) {
1364
+ replaceRange(
1365
+ editorInfo,
1366
+ [curLine, curChar],
1367
+ [curLine, curChar + 1],
1368
+ evt.key,
1369
+ );
1370
+ } else {
1371
+ replaceRange(
1372
+ editorInfo,
1373
+ [curLine, curChar],
1374
+ [curLine, curChar],
1375
+ evt.key,
1376
+ );
1377
+ }
1378
+ moveCursor(editorInfo, curLine, curChar + 1);
1379
+ evt.preventDefault();
1380
+ return true;
1381
+ }
1382
+ return false;
1383
+ }
1384
+
1323
1385
  if (state.searchMode) {
1324
1386
  if (evt.key === "Enter") {
1325
1387
  state.searchMode = false;
@@ -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
+ });