ep_vim 0.11.0 → 0.12.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
@@ -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.0",
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 ---
@@ -767,6 +768,15 @@ commands["visual-line"]["~"] = (ctx) => {
767
768
  moveBlockCursor(ctx.editorInfo, start[0], start[1]);
768
769
  };
769
770
 
771
+ commands.normal["gv"] = ({ editorInfo, rep }) => {
772
+ if (!state.lastVisualSelection) return;
773
+ const { anchor, cursor, mode } = state.lastVisualSelection;
774
+ state.visualAnchor = anchor;
775
+ state.visualCursor = cursor;
776
+ state.mode = mode;
777
+ updateVisualSelection(editorInfo, rep);
778
+ };
779
+
770
780
  // --- Miscellaneous ---
771
781
 
772
782
  commands.normal["u"] = ({ editorInfo }) => {
@@ -1056,6 +1066,18 @@ commands.normal["zz"] = ({ line }) => {
1056
1066
  if (lineDiv) lineDiv.scrollIntoView({ block: "center" });
1057
1067
  };
1058
1068
 
1069
+ commands.normal["zt"] = ({ line }) => {
1070
+ if (!state.editorDoc) return;
1071
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
1072
+ if (lineDiv) lineDiv.scrollIntoView({ block: "start" });
1073
+ };
1074
+
1075
+ commands.normal["zb"] = ({ line }) => {
1076
+ if (!state.editorDoc) return;
1077
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
1078
+ if (lineDiv) lineDiv.scrollIntoView({ block: "end" });
1079
+ };
1080
+
1059
1081
  // --- Dispatch ---
1060
1082
 
1061
1083
  const handleKey = (key, ctx) => {
@@ -1212,10 +1234,20 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1212
1234
  if (evt.key === "Escape") {
1213
1235
  state.desiredColumn = null;
1214
1236
  if (state.mode === "visual-line") {
1237
+ state.lastVisualSelection = {
1238
+ anchor: state.visualAnchor,
1239
+ cursor: state.visualCursor,
1240
+ mode: "visual-line",
1241
+ };
1215
1242
  const line = Math.min(state.visualAnchor[0], state.visualCursor[0]);
1216
1243
  state.mode = "normal";
1217
1244
  moveBlockCursor(editorInfo, line, 0);
1218
1245
  } else if (state.mode === "visual-char") {
1246
+ state.lastVisualSelection = {
1247
+ anchor: state.visualAnchor,
1248
+ cursor: state.visualCursor,
1249
+ mode: "visual-char",
1250
+ };
1219
1251
  const [vLine, vChar] = state.visualCursor;
1220
1252
  state.mode = "normal";
1221
1253
  moveBlockCursor(editorInfo, vLine, vChar);
@@ -1312,6 +1344,33 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1312
1344
  evt.preventDefault();
1313
1345
  return true;
1314
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;
1371
+ evt.preventDefault();
1372
+ return true;
1373
+ }
1315
1374
  }
1316
1375
 
1317
1376
  const handled = handleKey(evt.key, ctx);
@@ -1324,3 +1383,9 @@ exports._state = state;
1324
1383
  exports._handleKey = handleKey;
1325
1384
  exports._commands = commands;
1326
1385
  exports._parameterized = parameterized;
1386
+ exports._setVimEnabled = (v) => {
1387
+ vimEnabled = v;
1388
+ };
1389
+ exports._setUseCtrlKeys = (v) => {
1390
+ useCtrlKeys = v;
1391
+ };
@@ -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
+ });