ep_vim 0.12.1 → 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.1",
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": {
@@ -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;
@@ -869,6 +879,18 @@ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
869
879
  }
870
880
  };
871
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
+
872
894
  commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
873
895
  const reg = getActiveRegister();
874
896
  if (reg !== null) {
@@ -1290,9 +1312,9 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1290
1312
  cursor: state.visualCursor,
1291
1313
  mode: "visual-line",
1292
1314
  };
1293
- const line = Math.min(state.visualAnchor[0], state.visualCursor[0]);
1315
+ const [vLine, vChar] = state.visualCursor;
1294
1316
  state.mode = "normal";
1295
- moveBlockCursor(editorInfo, line, 0);
1317
+ moveBlockCursor(editorInfo, vLine, vChar);
1296
1318
  } else if (state.mode === "visual-char") {
1297
1319
  state.lastVisualSelection = {
1298
1320
  anchor: state.visualAnchor,
@@ -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
+ });
@@ -0,0 +1,363 @@
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("undo command", () => {
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("u calls undo", () => {
95
+ const rep = makeRep(["hello"]);
96
+ const { editorInfo: baseEditorInfo } = makeMockEditorInfo();
97
+
98
+ const editorInfo = {
99
+ ...baseEditorInfo,
100
+ ace_doUndoRedo: () => {},
101
+ };
102
+
103
+ const ctx = { rep, editorInfo, line: 0, char: 0, lineText: "hello" };
104
+ commands.normal["u"](ctx);
105
+ });
106
+ });
107
+
108
+ describe("repeat command", () => {
109
+ beforeEach(() => {
110
+ state.mode = "normal";
111
+ state.pendingKey = null;
112
+ state.pendingCount = null;
113
+ state.countBuffer = "";
114
+ state.register = null;
115
+ state.marks = {};
116
+ state.lastCharSearch = null;
117
+ state.visualAnchor = null;
118
+ state.visualCursor = null;
119
+ state.editorDoc = null;
120
+ state.currentRep = null;
121
+ state.desiredColumn = null;
122
+ state.lastCommand = null;
123
+ state.searchMode = false;
124
+ state.searchBuffer = "";
125
+ state.searchDirection = null;
126
+ state.lastSearch = null;
127
+ });
128
+
129
+ it(". repeats last command", () => {
130
+ const rep = makeRep(["hello"]);
131
+ const { editorInfo, calls } = makeMockEditorInfo();
132
+
133
+ state.lastCommand = { key: "h", count: 1, param: null };
134
+
135
+ const ctx = {
136
+ rep,
137
+ editorInfo,
138
+ line: 0,
139
+ char: 3,
140
+ lineText: "hello",
141
+ };
142
+ commands.normal["."](ctx);
143
+
144
+ assert.equal(calls.length, 1);
145
+ });
146
+
147
+ it(". does nothing with no last command", () => {
148
+ const rep = makeRep(["hello"]);
149
+ const { editorInfo, calls } = makeMockEditorInfo();
150
+
151
+ state.lastCommand = null;
152
+
153
+ const ctx = { rep, editorInfo, line: 0, char: 0, lineText: "hello" };
154
+ commands.normal["."](ctx);
155
+
156
+ assert.equal(calls.length, 0);
157
+ });
158
+
159
+ it(". repeats parameterized command", () => {
160
+ const rep = makeRep(["hello world"]);
161
+ const { editorInfo } = makeMockEditorInfo();
162
+
163
+ state.lastCommand = { key: "m", count: 1, param: "a" };
164
+
165
+ const ctx = {
166
+ rep,
167
+ editorInfo,
168
+ line: 0,
169
+ char: 2,
170
+ lineText: "hello world",
171
+ };
172
+ commands.normal["."](ctx);
173
+
174
+ assert.deepEqual(state.marks["a"], [0, 2]);
175
+ });
176
+ });
177
+
178
+ describe("missing feature: zz center screen", () => {
179
+ beforeEach(resetState);
180
+
181
+ it("zz does nothing when editorDoc is null", () => {
182
+ state.editorDoc = null;
183
+ const rep = makeRep(["hello"]);
184
+ const { editorInfo } = makeMockEditorInfo();
185
+ const ctx = {
186
+ rep,
187
+ editorInfo,
188
+ line: 0,
189
+ char: 0,
190
+ lineText: "hello",
191
+ count: 1,
192
+ hasCount: false,
193
+ };
194
+
195
+ assert.doesNotThrow(() => commands.normal["zz"](ctx));
196
+ });
197
+ });
198
+
199
+ describe("zt and zb scroll commands", () => {
200
+ beforeEach(resetState);
201
+
202
+ it("zt does nothing when editorDoc is null", () => {
203
+ state.editorDoc = null;
204
+ const rep = makeRep(["hello"]);
205
+ const { editorInfo } = makeMockEditorInfo();
206
+ const ctx = {
207
+ rep,
208
+ editorInfo,
209
+ line: 0,
210
+ char: 0,
211
+ lineText: "hello",
212
+ count: 1,
213
+ hasCount: false,
214
+ };
215
+ assert.doesNotThrow(() => commands.normal["zt"](ctx));
216
+ });
217
+
218
+ it("zb does nothing when editorDoc is null", () => {
219
+ state.editorDoc = null;
220
+ const rep = makeRep(["hello"]);
221
+ const { editorInfo } = makeMockEditorInfo();
222
+ const ctx = {
223
+ rep,
224
+ editorInfo,
225
+ line: 0,
226
+ char: 0,
227
+ lineText: "hello",
228
+ count: 1,
229
+ hasCount: false,
230
+ };
231
+ assert.doesNotThrow(() => commands.normal["zb"](ctx));
232
+ });
233
+
234
+ it("zt calls scrollIntoView with block: start", () => {
235
+ const scrollCalls = [];
236
+ const mockLineDiv = {
237
+ scrollIntoView: (opts) => scrollCalls.push(opts),
238
+ };
239
+ state.editorDoc = {
240
+ body: {
241
+ querySelectorAll: () => [mockLineDiv],
242
+ },
243
+ };
244
+ const rep = makeRep(["hello"]);
245
+ const { editorInfo } = makeMockEditorInfo();
246
+ const ctx = {
247
+ rep,
248
+ editorInfo,
249
+ line: 0,
250
+ char: 0,
251
+ lineText: "hello",
252
+ count: 1,
253
+ hasCount: false,
254
+ };
255
+ commands.normal["zt"](ctx);
256
+ assert.equal(scrollCalls.length, 1);
257
+ assert.deepEqual(scrollCalls[0], { block: "start" });
258
+ });
259
+
260
+ it("zb calls scrollIntoView with block: end", () => {
261
+ const scrollCalls = [];
262
+ const mockLineDiv = {
263
+ scrollIntoView: (opts) => scrollCalls.push(opts),
264
+ };
265
+ state.editorDoc = {
266
+ body: {
267
+ querySelectorAll: () => [mockLineDiv],
268
+ },
269
+ };
270
+ const rep = makeRep(["hello"]);
271
+ const { editorInfo } = makeMockEditorInfo();
272
+ const ctx = {
273
+ rep,
274
+ editorInfo,
275
+ line: 0,
276
+ char: 0,
277
+ lineText: "hello",
278
+ count: 1,
279
+ hasCount: false,
280
+ };
281
+ commands.normal["zb"](ctx);
282
+ assert.equal(scrollCalls.length, 1);
283
+ assert.deepEqual(scrollCalls[0], { block: "end" });
284
+ });
285
+ });
286
+
287
+ describe("Ctrl+f and Ctrl+b page scroll", () => {
288
+ beforeEach(() => {
289
+ resetState();
290
+ setVimEnabled(true);
291
+ setUseCtrlKeys(true);
292
+ });
293
+
294
+ const makeCtrlEvt = (key) => ({
295
+ type: "keydown",
296
+ key,
297
+ ctrlKey: true,
298
+ metaKey: false,
299
+ target: { ownerDocument: null },
300
+ preventDefault: () => {},
301
+ });
302
+
303
+ it("Ctrl+f moves forward one full page", () => {
304
+ const lines = Array.from({ length: 50 }, (_, i) => `line${i}`);
305
+ const rep = makeRep(lines);
306
+ rep.selStart = [0, 0];
307
+ const { editorInfo, calls } = makeMockEditorInfo();
308
+ aceKeyEvent("aceKeyEvent", {
309
+ evt: makeCtrlEvt("f"),
310
+ rep,
311
+ editorInfo,
312
+ });
313
+ const selectCall = calls.find((c) => c.type === "select");
314
+ assert.ok(selectCall, "expected a select call");
315
+ assert.equal(selectCall.start[0], 30);
316
+ });
317
+
318
+ it("Ctrl+b moves backward one full page", () => {
319
+ const lines = Array.from({ length: 50 }, (_, i) => `line${i}`);
320
+ const rep = makeRep(lines);
321
+ rep.selStart = [40, 0];
322
+ state.mode = "normal";
323
+ const { editorInfo, calls } = makeMockEditorInfo();
324
+ aceKeyEvent("aceKeyEvent", {
325
+ evt: makeCtrlEvt("b"),
326
+ rep,
327
+ editorInfo,
328
+ });
329
+ const selectCall = calls.find((c) => c.type === "select");
330
+ assert.ok(selectCall, "expected a select call");
331
+ assert.equal(selectCall.start[0], 10);
332
+ });
333
+
334
+ it("Ctrl+f clamps at end of document", () => {
335
+ const lines = Array.from({ length: 10 }, (_, i) => `line${i}`);
336
+ const rep = makeRep(lines);
337
+ rep.selStart = [5, 0];
338
+ const { editorInfo, calls } = makeMockEditorInfo();
339
+ aceKeyEvent("aceKeyEvent", {
340
+ evt: makeCtrlEvt("f"),
341
+ rep,
342
+ editorInfo,
343
+ });
344
+ const selectCall = calls.find((c) => c.type === "select");
345
+ assert.ok(selectCall, "expected a select call");
346
+ assert.equal(selectCall.start[0], 9);
347
+ });
348
+
349
+ it("Ctrl+b clamps at start of document", () => {
350
+ const lines = Array.from({ length: 10 }, (_, i) => `line${i}`);
351
+ const rep = makeRep(lines);
352
+ rep.selStart = [3, 0];
353
+ const { editorInfo, calls } = makeMockEditorInfo();
354
+ aceKeyEvent("aceKeyEvent", {
355
+ evt: makeCtrlEvt("b"),
356
+ rep,
357
+ editorInfo,
358
+ });
359
+ const selectCall = calls.find((c) => c.type === "select");
360
+ assert.ok(selectCall, "expected a select call");
361
+ assert.equal(selectCall.start[0], 0);
362
+ });
363
+ });