ep_vim 0.11.0 → 0.12.1

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
@@ -16,16 +16,18 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
16
16
  - **Bracket matching** — `%` jump to matching bracket
17
17
  - **Text objects** — `iw`/`aw` (word), `i"`/`a"` and `i'`/`a'` (quotes), `i{`/`a{` etc. (brackets), `ip`/`ap` (paragraph), `is`/`as` (sentence)
18
18
  - **Operators** — `d`, `c`, `y` with motion and text object combinations (`dw`, `ce`, `y$`, `ciw`, `da"`, `yi(`, etc.)
19
- - **Line operations** — `dd`, `cc`, `yy`, `J` (join), `Y` (yank line)
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
22
  - **Editing** — `i` `a` `A` `I` (insert/append), `x`, `r`, `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
23
23
  - **Marks** — `m{a-z}` to set, `'{a-z}` / `` `{a-z} `` to jump
24
- - **Search** — `/` and `?` forward/backward, `n`/`N` repeat
24
+ - **Search** — `/` and `?` forward/backward, `n`/`N` repeat, `*`/`#` search word under cursor
25
+ - **Scrolling** — `zz`/`zt`/`zb` center/top/bottom, `Ctrl+d`/`Ctrl+u` half-page, `Ctrl+f`/`Ctrl+b` full-page (requires ctrl keys enabled)
26
+ - **Visual** — `v` char, `V` line, `gv` reselect last selection; `~` toggle case in visual
25
27
  - **Repeat** — `.` repeat last command
26
28
  - **Counts** — numeric prefixes work with motions and operators
27
- - **Undo** — `u`
28
- - **Toggle** — toolbar button to enable/disable vim mode, persisted in localStorage
29
+ - **Undo/redo** — `u` undo, `Ctrl+r` redo (requires ctrl keys enabled)
30
+ - **Toggle** — toolbar button to enable/disable vim mode, persisted in localStorage; settings panel for system clipboard and ctrl key behavior
29
31
 
30
32
  ## Differences from vi
31
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_vim",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Vim-mode plugin for Etherpad with modal editing, motions, and operators",
5
5
  "author": {
6
6
  "name": "Seth Rothschild",
@@ -24,14 +24,14 @@
24
24
  "peerDependencies": {
25
25
  "ep_etherpad-lite": ">=1.8.6"
26
26
  },
27
+ "scripts": {
28
+ "test": "npm run format && node --test static/js/vim-core.test.js static/js/index.test.js",
29
+ "format": "prettier -w static/js/*"
30
+ },
27
31
  "engines": {
28
32
  "node": ">=20.0.0"
29
33
  },
30
34
  "dependencies": {
31
35
  "prettier": "^3.8.1"
32
- },
33
- "scripts": {
34
- "test": "npm run format && node --test static/js/vim-core.test.js static/js/index.test.js",
35
- "format": "prettier -w static/js/*"
36
36
  }
37
- }
37
+ }
@@ -54,6 +54,7 @@ const state = {
54
54
  searchBuffer: "",
55
55
  searchDirection: null,
56
56
  lastSearch: null,
57
+ lastVisualSelection: null,
57
58
  };
58
59
 
59
60
  // --- Editor operations ---
@@ -539,6 +540,8 @@ registerMotion("L", (ctx) => {
539
540
  };
540
541
  });
541
542
 
543
+ // --- Char search motions ---
544
+
542
545
  registerParamMotion("f", (key, ctx) => {
543
546
  const pos = charSearchPos("f", ctx.lineText, ctx.char, key, ctx.count);
544
547
  return pos !== -1 ? pos : null;
@@ -767,24 +770,32 @@ commands["visual-line"]["~"] = (ctx) => {
767
770
  moveBlockCursor(ctx.editorInfo, start[0], start[1]);
768
771
  };
769
772
 
770
- // --- Miscellaneous ---
773
+ commands["visual-char"]["i"] = () => {
774
+ state.pendingKey = "i";
775
+ };
771
776
 
772
- commands.normal["u"] = ({ editorInfo }) => {
773
- editorInfo.ace_doUndoRedo("undo");
777
+ commands["visual-char"]["a"] = () => {
778
+ state.pendingKey = "a";
774
779
  };
775
780
 
776
- commands.normal["."] = (ctx) => {
777
- if (!state.lastCommand) return;
778
- const { key, count, param } = state.lastCommand;
779
- if (param !== null && parameterized[key]) {
780
- parameterized[key](param, ctx);
781
- } else if (commands[state.mode] && commands[state.mode][key]) {
782
- const newCtx = { ...ctx, count };
783
- commands[state.mode][key](newCtx);
784
- }
781
+ commands["visual-line"]["i"] = () => {
782
+ state.pendingKey = "i";
783
+ };
784
+
785
+ commands["visual-line"]["a"] = () => {
786
+ state.pendingKey = "a";
785
787
  };
786
788
 
787
- // --- Mode transitions ---
789
+ commands.normal["gv"] = ({ editorInfo, rep }) => {
790
+ if (!state.lastVisualSelection) return;
791
+ const { anchor, cursor, mode } = state.lastVisualSelection;
792
+ state.visualAnchor = anchor;
793
+ state.visualCursor = cursor;
794
+ state.mode = mode;
795
+ updateVisualSelection(editorInfo, rep);
796
+ };
797
+
798
+ // --- Insert mode entry ---
788
799
 
789
800
  commands.normal["i"] = ({ editorInfo, line, char }) => {
790
801
  clearEmptyLineCursor();
@@ -810,22 +821,6 @@ commands.normal["I"] = ({ editorInfo, line, lineText }) => {
810
821
  state.mode = "insert";
811
822
  };
812
823
 
813
- commands["visual-char"]["i"] = () => {
814
- state.pendingKey = "i";
815
- };
816
-
817
- commands["visual-char"]["a"] = () => {
818
- state.pendingKey = "a";
819
- };
820
-
821
- commands["visual-line"]["i"] = () => {
822
- state.pendingKey = "i";
823
- };
824
-
825
- commands["visual-line"]["a"] = () => {
826
- state.pendingKey = "a";
827
- };
828
-
829
824
  commands.normal["o"] = ({ editorInfo, line, lineText }) => {
830
825
  clearEmptyLineCursor();
831
826
  replaceRange(
@@ -845,7 +840,7 @@ commands.normal["O"] = ({ editorInfo, line }) => {
845
840
  state.mode = "insert";
846
841
  };
847
842
 
848
- // --- More normal mode commands ---
843
+ // --- Editing commands ---
849
844
 
850
845
  commands.normal["r"] = () => {
851
846
  state.pendingKey = "r";
@@ -995,6 +990,27 @@ commands.normal["S"] = ({ editorInfo, line, lineText }) => {
995
990
  recordCommand("S", 1);
996
991
  };
997
992
 
993
+ // --- Undo, redo, repeat ---
994
+
995
+ commands.normal["u"] = ({ editorInfo }) => {
996
+ editorInfo.ace_doUndoRedo("undo");
997
+ };
998
+
999
+ commands.normal["<C-r>"] = ({ editorInfo }) => {
1000
+ editorInfo.ace_doUndoRedo("redo");
1001
+ };
1002
+
1003
+ commands.normal["."] = (ctx) => {
1004
+ if (!state.lastCommand) return;
1005
+ const { key, count, param } = state.lastCommand;
1006
+ if (param !== null && parameterized[key]) {
1007
+ parameterized[key](param, ctx);
1008
+ } else if (commands[state.mode] && commands[state.mode][key]) {
1009
+ const newCtx = { ...ctx, count };
1010
+ commands[state.mode][key](newCtx);
1011
+ }
1012
+ };
1013
+
998
1014
  // --- Search ---
999
1015
 
1000
1016
  commands.normal["/"] = () => {
@@ -1050,12 +1066,69 @@ commands.normal["#"] = (ctx) => {
1050
1066
  if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
1051
1067
  };
1052
1068
 
1069
+ // --- Scroll ---
1070
+
1053
1071
  commands.normal["zz"] = ({ line }) => {
1054
1072
  if (!state.editorDoc) return;
1055
1073
  const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
1056
1074
  if (lineDiv) lineDiv.scrollIntoView({ block: "center" });
1057
1075
  };
1058
1076
 
1077
+ commands.normal["zt"] = ({ line }) => {
1078
+ if (!state.editorDoc) return;
1079
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
1080
+ if (lineDiv) lineDiv.scrollIntoView({ block: "start" });
1081
+ };
1082
+
1083
+ commands.normal["zb"] = ({ line }) => {
1084
+ if (!state.editorDoc) return;
1085
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
1086
+ if (lineDiv) lineDiv.scrollIntoView({ block: "end" });
1087
+ };
1088
+
1089
+ const halfPage = 15;
1090
+ const fullPage = halfPage * 2;
1091
+
1092
+ commands.normal["<C-d>"] = ({ editorInfo, rep, line, char, count }) => {
1093
+ const target = Math.min(line + halfPage * count, rep.lines.length() - 1);
1094
+ const targetLen = rep.lines.atIndex(target).text.length;
1095
+ moveBlockCursor(
1096
+ editorInfo,
1097
+ target,
1098
+ Math.min(char, Math.max(0, targetLen - 1)),
1099
+ );
1100
+ };
1101
+
1102
+ commands.normal["<C-u>"] = ({ editorInfo, rep, line, char, count }) => {
1103
+ const target = Math.max(line - halfPage * count, 0);
1104
+ const targetLen = rep.lines.atIndex(target).text.length;
1105
+ moveBlockCursor(
1106
+ editorInfo,
1107
+ target,
1108
+ Math.min(char, Math.max(0, targetLen - 1)),
1109
+ );
1110
+ };
1111
+
1112
+ commands.normal["<C-f>"] = ({ editorInfo, rep, line, char, count }) => {
1113
+ const target = Math.min(line + fullPage * count, rep.lines.length() - 1);
1114
+ const targetLen = rep.lines.atIndex(target).text.length;
1115
+ moveBlockCursor(
1116
+ editorInfo,
1117
+ target,
1118
+ Math.min(char, Math.max(0, targetLen - 1)),
1119
+ );
1120
+ };
1121
+
1122
+ commands.normal["<C-b>"] = ({ editorInfo, rep, line, char, count }) => {
1123
+ const target = Math.max(line - fullPage * count, 0);
1124
+ const targetLen = rep.lines.atIndex(target).text.length;
1125
+ moveBlockCursor(
1126
+ editorInfo,
1127
+ target,
1128
+ Math.min(char, Math.max(0, targetLen - 1)),
1129
+ );
1130
+ };
1131
+
1059
1132
  // --- Dispatch ---
1060
1133
 
1061
1134
  const handleKey = (key, ctx) => {
@@ -1212,10 +1285,20 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1212
1285
  if (evt.key === "Escape") {
1213
1286
  state.desiredColumn = null;
1214
1287
  if (state.mode === "visual-line") {
1288
+ state.lastVisualSelection = {
1289
+ anchor: state.visualAnchor,
1290
+ cursor: state.visualCursor,
1291
+ mode: "visual-line",
1292
+ };
1215
1293
  const line = Math.min(state.visualAnchor[0], state.visualCursor[0]);
1216
1294
  state.mode = "normal";
1217
1295
  moveBlockCursor(editorInfo, line, 0);
1218
1296
  } else if (state.mode === "visual-char") {
1297
+ state.lastVisualSelection = {
1298
+ anchor: state.visualAnchor,
1299
+ cursor: state.visualCursor,
1300
+ mode: "visual-char",
1301
+ };
1219
1302
  const [vLine, vChar] = state.visualCursor;
1220
1303
  state.mode = "normal";
1221
1304
  moveBlockCursor(editorInfo, vLine, vChar);
@@ -1271,44 +1354,9 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1271
1354
  const ctx = { rep, editorInfo, line, char, lineText };
1272
1355
 
1273
1356
  if (useCtrlKeys && evt.ctrlKey && state.mode === "normal") {
1274
- if (state.countBuffer !== "") {
1275
- state.pendingCount = parseInt(state.countBuffer, 10);
1276
- state.countBuffer = "";
1277
- }
1278
- const count = state.pendingCount !== null ? state.pendingCount : 1;
1279
-
1280
- if (evt.key === "r") {
1281
- editorInfo.ace_doUndoRedo("redo");
1282
- state.pendingCount = null;
1283
- state.pendingRegister = null;
1284
- evt.preventDefault();
1285
- return true;
1286
- }
1287
-
1288
- const halfPage = 15;
1289
- if (evt.key === "d") {
1290
- const target = Math.min(line + halfPage * count, rep.lines.length() - 1);
1291
- const targetLen = rep.lines.atIndex(target).text.length;
1292
- moveBlockCursor(
1293
- editorInfo,
1294
- target,
1295
- Math.min(char, Math.max(0, targetLen - 1)),
1296
- );
1297
- state.pendingCount = null;
1298
- state.pendingRegister = null;
1299
- evt.preventDefault();
1300
- return true;
1301
- }
1302
- if (evt.key === "u") {
1303
- const target = Math.max(line - halfPage * count, 0);
1304
- const targetLen = rep.lines.atIndex(target).text.length;
1305
- moveBlockCursor(
1306
- editorInfo,
1307
- target,
1308
- Math.min(char, Math.max(0, targetLen - 1)),
1309
- );
1310
- state.pendingCount = null;
1311
- state.pendingRegister = null;
1357
+ const ctrlKey = "<C-" + evt.key + ">";
1358
+ if (commands.normal[ctrlKey]) {
1359
+ handleKey(ctrlKey, ctx);
1312
1360
  evt.preventDefault();
1313
1361
  return true;
1314
1362
  }
@@ -1324,3 +1372,9 @@ exports._state = state;
1324
1372
  exports._handleKey = handleKey;
1325
1373
  exports._commands = commands;
1326
1374
  exports._parameterized = parameterized;
1375
+ exports._setVimEnabled = (v) => {
1376
+ vimEnabled = v;
1377
+ };
1378
+ exports._setUseCtrlKeys = (v) => {
1379
+ useCtrlKeys = v;
1380
+ };
@@ -15,6 +15,9 @@ const {
15
15
  _handleKey: handleKey,
16
16
  _commands: commands,
17
17
  _parameterized: parameterized,
18
+ _setVimEnabled: setVimEnabled,
19
+ _setUseCtrlKeys: setUseCtrlKeys,
20
+ aceKeyEvent,
18
21
  } = require("./index.js");
19
22
 
20
23
  const makeRep = (lines) => ({
@@ -2022,6 +2025,7 @@ const resetState = () => {
2022
2025
  state.searchBuffer = "";
2023
2026
  state.searchDirection = null;
2024
2027
  state.lastSearch = null;
2028
+ state.lastVisualSelection = null;
2025
2029
  };
2026
2030
 
2027
2031
  describe("edge cases: cc with count", () => {
@@ -3730,3 +3734,290 @@ describe("missing feature: zz center screen", () => {
3730
3734
  assert.doesNotThrow(() => commands.normal["zz"](ctx));
3731
3735
  });
3732
3736
  });
3737
+
3738
+ describe("zt and zb scroll commands", () => {
3739
+ beforeEach(resetState);
3740
+
3741
+ it("zt does nothing when editorDoc is null", () => {
3742
+ state.editorDoc = null;
3743
+ const rep = makeRep(["hello"]);
3744
+ const { editorInfo } = makeMockEditorInfo();
3745
+ const ctx = {
3746
+ rep,
3747
+ editorInfo,
3748
+ line: 0,
3749
+ char: 0,
3750
+ lineText: "hello",
3751
+ count: 1,
3752
+ hasCount: false,
3753
+ };
3754
+ assert.doesNotThrow(() => commands.normal["zt"](ctx));
3755
+ });
3756
+
3757
+ it("zb does nothing when editorDoc is null", () => {
3758
+ state.editorDoc = null;
3759
+ const rep = makeRep(["hello"]);
3760
+ const { editorInfo } = makeMockEditorInfo();
3761
+ const ctx = {
3762
+ rep,
3763
+ editorInfo,
3764
+ line: 0,
3765
+ char: 0,
3766
+ lineText: "hello",
3767
+ count: 1,
3768
+ hasCount: false,
3769
+ };
3770
+ assert.doesNotThrow(() => commands.normal["zb"](ctx));
3771
+ });
3772
+
3773
+ it("zt calls scrollIntoView with block: start", () => {
3774
+ const scrollCalls = [];
3775
+ const mockLineDiv = {
3776
+ scrollIntoView: (opts) => scrollCalls.push(opts),
3777
+ };
3778
+ state.editorDoc = {
3779
+ body: {
3780
+ querySelectorAll: () => [mockLineDiv],
3781
+ },
3782
+ };
3783
+ const rep = makeRep(["hello"]);
3784
+ const { editorInfo } = makeMockEditorInfo();
3785
+ const ctx = {
3786
+ rep,
3787
+ editorInfo,
3788
+ line: 0,
3789
+ char: 0,
3790
+ lineText: "hello",
3791
+ count: 1,
3792
+ hasCount: false,
3793
+ };
3794
+ commands.normal["zt"](ctx);
3795
+ assert.equal(scrollCalls.length, 1);
3796
+ assert.deepEqual(scrollCalls[0], { block: "start" });
3797
+ });
3798
+
3799
+ it("zb calls scrollIntoView with block: end", () => {
3800
+ const scrollCalls = [];
3801
+ const mockLineDiv = {
3802
+ scrollIntoView: (opts) => scrollCalls.push(opts),
3803
+ };
3804
+ state.editorDoc = {
3805
+ body: {
3806
+ querySelectorAll: () => [mockLineDiv],
3807
+ },
3808
+ };
3809
+ const rep = makeRep(["hello"]);
3810
+ const { editorInfo } = makeMockEditorInfo();
3811
+ const ctx = {
3812
+ rep,
3813
+ editorInfo,
3814
+ line: 0,
3815
+ char: 0,
3816
+ lineText: "hello",
3817
+ count: 1,
3818
+ hasCount: false,
3819
+ };
3820
+ commands.normal["zb"](ctx);
3821
+ assert.equal(scrollCalls.length, 1);
3822
+ assert.deepEqual(scrollCalls[0], { block: "end" });
3823
+ });
3824
+ });
3825
+
3826
+ describe("gv reselect last visual", () => {
3827
+ beforeEach(resetState);
3828
+
3829
+ it("does nothing when lastVisualSelection is null", () => {
3830
+ state.lastVisualSelection = null;
3831
+ const rep = makeRep(["hello world"]);
3832
+ const { editorInfo, calls } = makeMockEditorInfo();
3833
+ const ctx = {
3834
+ rep,
3835
+ editorInfo,
3836
+ line: 0,
3837
+ char: 0,
3838
+ lineText: "hello world",
3839
+ count: 1,
3840
+ hasCount: false,
3841
+ };
3842
+ commands.normal["gv"](ctx);
3843
+ assert.equal(calls.length, 0);
3844
+ assert.equal(state.mode, "normal");
3845
+ });
3846
+
3847
+ it("restores visual-char selection", () => {
3848
+ state.lastVisualSelection = {
3849
+ anchor: [0, 2],
3850
+ cursor: [0, 5],
3851
+ mode: "visual-char",
3852
+ };
3853
+ const rep = makeRep(["hello world"]);
3854
+ const { editorInfo, calls } = makeMockEditorInfo();
3855
+ const ctx = {
3856
+ rep,
3857
+ editorInfo,
3858
+ line: 0,
3859
+ char: 0,
3860
+ lineText: "hello world",
3861
+ count: 1,
3862
+ hasCount: false,
3863
+ };
3864
+ commands.normal["gv"](ctx);
3865
+ assert.equal(state.mode, "visual-char");
3866
+ assert.deepEqual(state.visualAnchor, [0, 2]);
3867
+ assert.deepEqual(state.visualCursor, [0, 5]);
3868
+ assert.ok(calls.length > 0, "expected a selection call");
3869
+ });
3870
+
3871
+ it("restores visual-line selection", () => {
3872
+ state.lastVisualSelection = {
3873
+ anchor: [1, 0],
3874
+ cursor: [2, 0],
3875
+ mode: "visual-line",
3876
+ };
3877
+ const rep = makeRep(["aaa", "bbb", "ccc"]);
3878
+ const { editorInfo } = makeMockEditorInfo();
3879
+ const ctx = {
3880
+ rep,
3881
+ editorInfo,
3882
+ line: 0,
3883
+ char: 0,
3884
+ lineText: "aaa",
3885
+ count: 1,
3886
+ hasCount: false,
3887
+ };
3888
+ commands.normal["gv"](ctx);
3889
+ assert.equal(state.mode, "visual-line");
3890
+ assert.deepEqual(state.visualAnchor, [1, 0]);
3891
+ assert.deepEqual(state.visualCursor, [2, 0]);
3892
+ });
3893
+
3894
+ it("escape from visual-char saves lastVisualSelection", () => {
3895
+ state.mode = "visual-char";
3896
+ state.visualAnchor = [0, 1];
3897
+ state.visualCursor = [0, 4];
3898
+ state.editorDoc = null;
3899
+ const rep = makeRep(["hello world"]);
3900
+ rep.selStart = [0, 4];
3901
+ const { editorInfo } = makeMockEditorInfo();
3902
+ const mockEvt = {
3903
+ type: "keydown",
3904
+ key: "Escape",
3905
+ ctrlKey: false,
3906
+ metaKey: false,
3907
+ target: { ownerDocument: null },
3908
+ preventDefault: () => {},
3909
+ };
3910
+ setVimEnabled(true);
3911
+ aceKeyEvent("aceKeyEvent", { evt: mockEvt, rep, editorInfo });
3912
+ setVimEnabled(false);
3913
+ assert.deepEqual(state.lastVisualSelection, {
3914
+ anchor: [0, 1],
3915
+ cursor: [0, 4],
3916
+ mode: "visual-char",
3917
+ });
3918
+ });
3919
+
3920
+ it("escape from visual-line saves lastVisualSelection", () => {
3921
+ state.mode = "visual-line";
3922
+ state.visualAnchor = [0, 0];
3923
+ state.visualCursor = [2, 0];
3924
+ state.editorDoc = null;
3925
+ const rep = makeRep(["aaa", "bbb", "ccc"]);
3926
+ rep.selStart = [2, 0];
3927
+ const { editorInfo } = makeMockEditorInfo();
3928
+ const mockEvt = {
3929
+ type: "keydown",
3930
+ key: "Escape",
3931
+ ctrlKey: false,
3932
+ metaKey: false,
3933
+ target: { ownerDocument: null },
3934
+ preventDefault: () => {},
3935
+ };
3936
+ setVimEnabled(true);
3937
+ aceKeyEvent("aceKeyEvent", { evt: mockEvt, rep, editorInfo });
3938
+ setVimEnabled(false);
3939
+ assert.deepEqual(state.lastVisualSelection, {
3940
+ anchor: [0, 0],
3941
+ cursor: [2, 0],
3942
+ mode: "visual-line",
3943
+ });
3944
+ });
3945
+ });
3946
+
3947
+ describe("Ctrl+f and Ctrl+b page scroll", () => {
3948
+ beforeEach(() => {
3949
+ resetState();
3950
+ setVimEnabled(true);
3951
+ setUseCtrlKeys(true);
3952
+ });
3953
+
3954
+ const makeCtrlEvt = (key) => ({
3955
+ type: "keydown",
3956
+ key,
3957
+ ctrlKey: true,
3958
+ metaKey: false,
3959
+ target: { ownerDocument: null },
3960
+ preventDefault: () => {},
3961
+ });
3962
+
3963
+ it("Ctrl+f moves forward one full page", () => {
3964
+ const lines = Array.from({ length: 50 }, (_, i) => `line${i}`);
3965
+ const rep = makeRep(lines);
3966
+ rep.selStart = [0, 0];
3967
+ const { editorInfo, calls } = makeMockEditorInfo();
3968
+ aceKeyEvent("aceKeyEvent", {
3969
+ evt: makeCtrlEvt("f"),
3970
+ rep,
3971
+ editorInfo,
3972
+ });
3973
+ const selectCall = calls.find((c) => c.type === "select");
3974
+ assert.ok(selectCall, "expected a select call");
3975
+ assert.equal(selectCall.start[0], 30);
3976
+ });
3977
+
3978
+ it("Ctrl+b moves backward one full page", () => {
3979
+ const lines = Array.from({ length: 50 }, (_, i) => `line${i}`);
3980
+ const rep = makeRep(lines);
3981
+ rep.selStart = [40, 0];
3982
+ state.mode = "normal";
3983
+ const { editorInfo, calls } = makeMockEditorInfo();
3984
+ aceKeyEvent("aceKeyEvent", {
3985
+ evt: makeCtrlEvt("b"),
3986
+ rep,
3987
+ editorInfo,
3988
+ });
3989
+ const selectCall = calls.find((c) => c.type === "select");
3990
+ assert.ok(selectCall, "expected a select call");
3991
+ assert.equal(selectCall.start[0], 10);
3992
+ });
3993
+
3994
+ it("Ctrl+f clamps at end of document", () => {
3995
+ const lines = Array.from({ length: 10 }, (_, i) => `line${i}`);
3996
+ const rep = makeRep(lines);
3997
+ rep.selStart = [5, 0];
3998
+ const { editorInfo, calls } = makeMockEditorInfo();
3999
+ aceKeyEvent("aceKeyEvent", {
4000
+ evt: makeCtrlEvt("f"),
4001
+ rep,
4002
+ editorInfo,
4003
+ });
4004
+ const selectCall = calls.find((c) => c.type === "select");
4005
+ assert.ok(selectCall, "expected a select call");
4006
+ assert.equal(selectCall.start[0], 9);
4007
+ });
4008
+
4009
+ it("Ctrl+b clamps at start of document", () => {
4010
+ const lines = Array.from({ length: 10 }, (_, i) => `line${i}`);
4011
+ const rep = makeRep(lines);
4012
+ rep.selStart = [3, 0];
4013
+ const { editorInfo, calls } = makeMockEditorInfo();
4014
+ aceKeyEvent("aceKeyEvent", {
4015
+ evt: makeCtrlEvt("b"),
4016
+ rep,
4017
+ editorInfo,
4018
+ });
4019
+ const selectCall = calls.find((c) => c.type === "select");
4020
+ assert.ok(selectCall, "expected a select call");
4021
+ assert.equal(selectCall.start[0], 0);
4022
+ });
4023
+ });