ep_vim 0.9.3 → 0.11.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
@@ -10,15 +10,16 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
10
10
 
11
11
  ## Features
12
12
 
13
- - **Modal editing** — normal, insert, and visual (char + line) modes
13
+ - **Modal editing** — normal, insert, and visual (char + line) modes; visual selections support all operators (`d`, `c`, `y`)
14
14
  - **Motions** — `h` `j` `k` `l`, `w` `b` `e`, `0` `$` `^`, `gg` `G`, `f`/`F`/`t`/`T` char search, `{` `}` paragraph forward/backward, `H` `M` `L` viewport (top/middle/bottom)
15
15
  - **Char search** — `f`/`F`/`t`/`T` find, `;` repeat last search, `,` reverse direction
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
19
  - **Line operations** — `dd`, `cc`, `yy`, `J` (join), `Y` (yank line)
20
+ - **Registers** — `"a`–`"z` named registers for yank/delete/put, `"_` blackhole register
20
21
  - **Put** — `p` / `P` with linewise and characterwise register handling
21
- - **Editing** — `x`, `r`, `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
22
+ - **Editing** — `i` `a` `A` `I` (insert/append), `x`, `r`, `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
22
23
  - **Marks** — `m{a-z}` to set, `'{a-z}` / `` `{a-z} `` to jump
23
24
  - **Search** — `/` and `?` forward/backward, `n`/`N` repeat
24
25
  - **Repeat** — `.` repeat last command
@@ -26,12 +27,16 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
26
27
  - **Undo** — `u`
27
28
  - **Toggle** — toolbar button to enable/disable vim mode, persisted in localStorage
28
29
 
30
+ ## Differences from vi
31
+
32
+ - **No command line, macros, or globals** - these are not planned, but PRs welcome.
33
+
29
34
  ## Installation
30
35
 
31
- Clone or symlink into your Etherpad plugins directory, then install:
36
+ From your Etherpad directory run
32
37
 
33
38
  ```
34
- pnpm install ep_vim
39
+ pnpm run plugins install ep_vim
35
40
  ```
36
41
 
37
42
 
package/ep.json CHANGED
@@ -9,7 +9,8 @@
9
9
  "postToolbarInit": "ep_vim/static/js/index"
10
10
  },
11
11
  "hooks": {
12
- "eejsBlock_editbarMenuLeft": "ep_vim/index"
12
+ "eejsBlock_editbarMenuLeft": "ep_vim/index",
13
+ "eejsBlock_mySettings": "ep_vim/index"
13
14
  }
14
15
  }
15
16
  ]
package/index.js CHANGED
@@ -6,3 +6,8 @@ exports.eejsBlock_editbarMenuLeft = (hook, args, cb) => {
6
6
  args.content += eejs.require('ep_vim/templates/editbarButtons.ejs');
7
7
  return cb();
8
8
  };
9
+
10
+ exports.eejsBlock_mySettings = (_hook, args, cb) => {
11
+ args.content += eejs.require('ep_vim/templates/settings.ejs');
12
+ return cb();
13
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_vim",
3
- "version": "0.9.3",
3
+ "version": "0.11.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
- },
31
27
  "engines": {
32
28
  "node": ">=20.0.0"
33
29
  },
34
30
  "dependencies": {
35
31
  "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
+ }
@@ -30,12 +30,18 @@ let vimEnabled =
30
30
  typeof localStorage !== "undefined" &&
31
31
  localStorage.getItem("ep_vimEnabled") === "true";
32
32
 
33
+ let useSystemClipboard = true;
34
+ let useCtrlKeys = true;
35
+
33
36
  const state = {
34
37
  mode: "normal",
35
38
  pendingKey: null,
36
39
  pendingCount: null,
37
40
  countBuffer: "",
38
41
  register: null,
42
+ namedRegisters: {},
43
+ pendingRegister: null,
44
+ awaitingRegister: false,
39
45
  marks: {},
40
46
  lastCharSearch: null,
41
47
  visualAnchor: null,
@@ -53,13 +59,34 @@ const state = {
53
59
  // --- Editor operations ---
54
60
 
55
61
  const setRegister = (value) => {
62
+ if (state.pendingRegister === "_") {
63
+ return;
64
+ }
65
+ if (state.pendingRegister && /^[a-zA-Z]$/.test(state.pendingRegister)) {
66
+ const name = state.pendingRegister.toLowerCase();
67
+ state.namedRegisters[name] = value;
68
+ return;
69
+ }
56
70
  state.register = value;
57
71
  const text = Array.isArray(value) ? value.join("\n") + "\n" : value;
58
- if (navigator.clipboard) {
72
+ if (useSystemClipboard && navigator.clipboard) {
59
73
  navigator.clipboard.writeText(text).catch(() => {});
60
74
  }
61
75
  };
62
76
 
77
+ const getActiveRegister = () => {
78
+ if (state.pendingRegister === "_") {
79
+ return null;
80
+ }
81
+ if (state.pendingRegister && /^[a-zA-Z]$/.test(state.pendingRegister)) {
82
+ const name = state.pendingRegister.toLowerCase();
83
+ return state.namedRegisters[name] !== undefined
84
+ ? state.namedRegisters[name]
85
+ : null;
86
+ }
87
+ return state.register;
88
+ };
89
+
63
90
  const moveCursor = (editorInfo, line, char) => {
64
91
  const pos = [line, char];
65
92
  editorInfo.ace_inCallStackIfNecessary("vim-move", () => {
@@ -833,6 +860,7 @@ parameterized["r"] = (key, { editorInfo, line, char, lineText, count }) => {
833
860
 
834
861
  commands.normal["Y"] = ({ rep, line }) => {
835
862
  setRegister([getLineText(rep, line)]);
863
+ recordCommand("Y", 1);
836
864
  };
837
865
 
838
866
  commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
@@ -847,14 +875,15 @@ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
847
875
  };
848
876
 
849
877
  commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
850
- if (state.register !== null) {
851
- if (typeof state.register === "string") {
878
+ const reg = getActiveRegister();
879
+ if (reg !== null) {
880
+ if (typeof reg === "string") {
852
881
  const insertPos = Math.min(char + 1, lineText.length);
853
- const repeated = state.register.repeat(count);
882
+ const repeated = reg.repeat(count);
854
883
  replaceRange(editorInfo, [line, insertPos], [line, insertPos], repeated);
855
- moveBlockCursor(editorInfo, line, insertPos);
884
+ moveBlockCursor(editorInfo, line, insertPos + repeated.length - 1);
856
885
  } else {
857
- const block = state.register.join("\n");
886
+ const block = reg.join("\n");
858
887
  const parts = [];
859
888
  for (let i = 0; i < count; i++) parts.push(block);
860
889
  const insertText = "\n" + parts.join("\n");
@@ -864,25 +893,26 @@ commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
864
893
  [line, lineText.length],
865
894
  insertText,
866
895
  );
867
- moveBlockCursor(editorInfo, line + 1, 0);
896
+ moveBlockCursor(editorInfo, line + 1, firstNonBlank(reg[0]));
868
897
  }
869
898
  recordCommand("p", count);
870
899
  }
871
900
  };
872
901
 
873
902
  commands.normal["P"] = ({ editorInfo, line, char, count }) => {
874
- if (state.register !== null) {
875
- if (typeof state.register === "string") {
876
- const repeated = state.register.repeat(count);
903
+ const reg = getActiveRegister();
904
+ if (reg !== null) {
905
+ if (typeof reg === "string") {
906
+ const repeated = reg.repeat(count);
877
907
  replaceRange(editorInfo, [line, char], [line, char], repeated);
878
- moveBlockCursor(editorInfo, line, char);
908
+ moveBlockCursor(editorInfo, line, char + repeated.length - 1);
879
909
  } else {
880
- const block = state.register.join("\n");
910
+ const block = reg.join("\n");
881
911
  const parts = [];
882
912
  for (let i = 0; i < count; i++) parts.push(block);
883
913
  const insertText = parts.join("\n") + "\n";
884
914
  replaceRange(editorInfo, [line, 0], [line, 0], insertText);
885
- moveBlockCursor(editorInfo, line, 0);
915
+ moveBlockCursor(editorInfo, line, firstNonBlank(reg[0]));
886
916
  }
887
917
  recordCommand("P", count);
888
918
  }
@@ -944,7 +974,7 @@ commands.normal["C"] = ({ editorInfo, line, char, lineText }) => {
944
974
 
945
975
  commands.normal["s"] = ({ editorInfo, rep, line, char, lineText, count }) => {
946
976
  clearEmptyLineCursor();
947
- setRegister(lineText.slice(char, char + 1));
977
+ setRegister(lineText.slice(char, Math.min(char + count, lineText.length)));
948
978
  replaceRange(
949
979
  editorInfo,
950
980
  [line, char],
@@ -958,7 +988,7 @@ commands.normal["s"] = ({ editorInfo, rep, line, char, lineText, count }) => {
958
988
 
959
989
  commands.normal["S"] = ({ editorInfo, line, lineText }) => {
960
990
  clearEmptyLineCursor();
961
- setRegister(lineText);
991
+ setRegister([lineText]);
962
992
  replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
963
993
  moveCursor(editorInfo, line, 0);
964
994
  state.mode = "insert";
@@ -995,9 +1025,50 @@ commands.normal["N"] = (ctx) => {
995
1025
  if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
996
1026
  };
997
1027
 
1028
+ const getWordAt = (text, char) => {
1029
+ if (char >= text.length || !/\w/.test(text[char])) return null;
1030
+ let start = char;
1031
+ while (start > 0 && /\w/.test(text[start - 1])) start--;
1032
+ let end = char;
1033
+ while (end < text.length && /\w/.test(text[end])) end++;
1034
+ return text.slice(start, end);
1035
+ };
1036
+
1037
+ commands.normal["*"] = (ctx) => {
1038
+ const word = getWordAt(ctx.lineText, ctx.char);
1039
+ if (!word) return;
1040
+ state.lastSearch = { pattern: word, direction: "/" };
1041
+ const pos = searchForward(ctx.rep, ctx.line, ctx.char + 1, word, ctx.count);
1042
+ if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
1043
+ };
1044
+
1045
+ commands.normal["#"] = (ctx) => {
1046
+ const word = getWordAt(ctx.lineText, ctx.char);
1047
+ if (!word) return;
1048
+ state.lastSearch = { pattern: word, direction: "?" };
1049
+ const pos = searchBackward(ctx.rep, ctx.line, ctx.char, word, ctx.count);
1050
+ if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
1051
+ };
1052
+
1053
+ commands.normal["zz"] = ({ line }) => {
1054
+ if (!state.editorDoc) return;
1055
+ const lineDiv = state.editorDoc.body.querySelectorAll("div")[line];
1056
+ if (lineDiv) lineDiv.scrollIntoView({ block: "center" });
1057
+ };
1058
+
998
1059
  // --- Dispatch ---
999
1060
 
1000
1061
  const handleKey = (key, ctx) => {
1062
+ if (state.awaitingRegister) {
1063
+ state.pendingRegister = key;
1064
+ state.awaitingRegister = false;
1065
+ return true;
1066
+ }
1067
+ if (key === '"' && state.mode === "normal") {
1068
+ state.awaitingRegister = true;
1069
+ return true;
1070
+ }
1071
+
1001
1072
  if (key >= "1" && key <= "9") {
1002
1073
  state.countBuffer += key;
1003
1074
  return true;
@@ -1019,6 +1090,7 @@ const handleKey = (key, ctx) => {
1019
1090
  state.pendingKey = null;
1020
1091
  handler(key, ctx);
1021
1092
  state.pendingCount = null;
1093
+ state.pendingRegister = null;
1022
1094
  return true;
1023
1095
  }
1024
1096
 
@@ -1028,7 +1100,10 @@ const handleKey = (key, ctx) => {
1028
1100
  if (map[seq]) {
1029
1101
  state.pendingKey = null;
1030
1102
  map[seq](ctx);
1031
- if (state.pendingKey === null) state.pendingCount = null;
1103
+ if (state.pendingKey === null) {
1104
+ state.pendingCount = null;
1105
+ state.pendingRegister = null;
1106
+ }
1032
1107
  return true;
1033
1108
  }
1034
1109
 
@@ -1073,6 +1148,7 @@ const handleKey = (key, ctx) => {
1073
1148
 
1074
1149
  state.pendingKey = null;
1075
1150
  state.pendingCount = null;
1151
+ state.pendingRegister = null;
1076
1152
  return true;
1077
1153
  };
1078
1154
 
@@ -1089,6 +1165,22 @@ exports.postToolbarInit = (_hookName, _args) => {
1089
1165
  localStorage.setItem("ep_vimEnabled", vimEnabled ? "true" : "false");
1090
1166
  btn.classList.toggle("vim-enabled", vimEnabled);
1091
1167
  });
1168
+
1169
+ const clipboardCheckbox = document.getElementById(
1170
+ "options-vim-use-system-clipboard",
1171
+ );
1172
+ if (!clipboardCheckbox) return;
1173
+ useSystemClipboard = clipboardCheckbox.checked;
1174
+ clipboardCheckbox.addEventListener("change", () => {
1175
+ useSystemClipboard = clipboardCheckbox.checked;
1176
+ });
1177
+
1178
+ const ctrlKeysCheckbox = document.getElementById("options-vim-use-ctrl-keys");
1179
+ if (!ctrlKeysCheckbox) return;
1180
+ useCtrlKeys = ctrlKeysCheckbox.checked;
1181
+ ctrlKeysCheckbox.addEventListener("change", () => {
1182
+ useCtrlKeys = ctrlKeysCheckbox.checked;
1183
+ });
1092
1184
  };
1093
1185
 
1094
1186
  exports.postAceInit = (_hookName, { ace }) => {
@@ -1108,7 +1200,10 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1108
1200
 
1109
1201
  const isBrowserShortcut =
1110
1202
  (evt.ctrlKey || evt.metaKey) &&
1111
- (evt.key === "x" || evt.key === "c" || evt.key === "v" || evt.key === "r");
1203
+ (evt.key === "x" ||
1204
+ evt.key === "c" ||
1205
+ evt.key === "v" ||
1206
+ (evt.key === "r" && !useCtrlKeys));
1112
1207
  if (isBrowserShortcut) return false;
1113
1208
 
1114
1209
  state.currentRep = rep;
@@ -1174,6 +1269,51 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
1174
1269
  : rep.selStart;
1175
1270
  const lineText = rep.lines.atIndex(line).text;
1176
1271
  const ctx = { rep, editorInfo, line, char, lineText };
1272
+
1273
+ 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;
1312
+ evt.preventDefault();
1313
+ return true;
1314
+ }
1315
+ }
1316
+
1177
1317
  const handled = handleKey(evt.key, ctx);
1178
1318
  if (handled) evt.preventDefault();
1179
1319
  return handled;
@@ -2007,6 +2007,9 @@ const resetState = () => {
2007
2007
  state.pendingCount = null;
2008
2008
  state.countBuffer = "";
2009
2009
  state.register = null;
2010
+ state.namedRegisters = {};
2011
+ state.pendingRegister = null;
2012
+ state.awaitingRegister = false;
2010
2013
  state.marks = {};
2011
2014
  state.lastCharSearch = null;
2012
2015
  state.visualAnchor = null;
@@ -3079,3 +3082,651 @@ describe("edge cases: df and dt (operator + char motion)", () => {
3079
3082
  );
3080
3083
  });
3081
3084
  });
3085
+
3086
+ // --- Register bugs ---
3087
+
3088
+ describe("register bug: s ignores count", () => {
3089
+ beforeEach(resetState);
3090
+
3091
+ it("s with count=3 saves 3 chars to register", () => {
3092
+ const rep = makeRep(["abcdef"]);
3093
+ const { editorInfo } = makeMockEditorInfo();
3094
+
3095
+ const ctx = {
3096
+ rep,
3097
+ editorInfo,
3098
+ line: 0,
3099
+ char: 1,
3100
+ lineText: "abcdef",
3101
+ count: 3,
3102
+ };
3103
+ commands.normal["s"](ctx);
3104
+
3105
+ assert.equal(state.register, "bcd", "3s should save 3 chars, not 1");
3106
+ });
3107
+
3108
+ it("s with count=1 saves 1 char to register", () => {
3109
+ const rep = makeRep(["abcdef"]);
3110
+ const { editorInfo } = makeMockEditorInfo();
3111
+
3112
+ const ctx = {
3113
+ rep,
3114
+ editorInfo,
3115
+ line: 0,
3116
+ char: 2,
3117
+ lineText: "abcdef",
3118
+ count: 1,
3119
+ };
3120
+ commands.normal["s"](ctx);
3121
+
3122
+ assert.equal(state.register, "c");
3123
+ });
3124
+
3125
+ it("s with count exceeding line saves up to end of line", () => {
3126
+ const rep = makeRep(["abc"]);
3127
+ const { editorInfo } = makeMockEditorInfo();
3128
+
3129
+ const ctx = {
3130
+ rep,
3131
+ editorInfo,
3132
+ line: 0,
3133
+ char: 1,
3134
+ lineText: "abc",
3135
+ count: 10,
3136
+ };
3137
+ commands.normal["s"](ctx);
3138
+
3139
+ assert.equal(
3140
+ state.register,
3141
+ "bc",
3142
+ "s with large count should save remaining chars",
3143
+ );
3144
+ });
3145
+ });
3146
+
3147
+ describe("register bug: S uses char-wise register instead of line-wise", () => {
3148
+ beforeEach(resetState);
3149
+
3150
+ it("S saves register as array (line-wise)", () => {
3151
+ const rep = makeRep(["hello world"]);
3152
+ const { editorInfo } = makeMockEditorInfo();
3153
+
3154
+ const ctx = {
3155
+ rep,
3156
+ editorInfo,
3157
+ line: 0,
3158
+ char: 0,
3159
+ lineText: "hello world",
3160
+ count: 1,
3161
+ };
3162
+ commands.normal["S"](ctx);
3163
+
3164
+ assert.ok(
3165
+ Array.isArray(state.register),
3166
+ "S should store register as array (line-wise), not string",
3167
+ );
3168
+ assert.deepEqual(state.register, ["hello world"]);
3169
+ });
3170
+
3171
+ it("S register is line-wise so p pastes on new line", () => {
3172
+ const rep = makeRep(["hello"]);
3173
+ const { editorInfo, calls } = makeMockEditorInfo();
3174
+
3175
+ const ctx = {
3176
+ rep,
3177
+ editorInfo,
3178
+ line: 0,
3179
+ char: 0,
3180
+ lineText: "hello",
3181
+ count: 1,
3182
+ };
3183
+ commands.normal["S"](ctx);
3184
+
3185
+ assert.ok(
3186
+ Array.isArray(state.register),
3187
+ "register should be line-wise after S",
3188
+ );
3189
+
3190
+ const replaces = calls.filter((c) => c.type === "replace");
3191
+ assert.ok(replaces.length > 0);
3192
+ assert.equal(replaces[0].newText, "", "S should clear the line");
3193
+ });
3194
+ });
3195
+
3196
+ describe("register bug: p cursor at start of paste instead of end", () => {
3197
+ beforeEach(resetState);
3198
+
3199
+ it("p char-wise: cursor lands at last char of pasted text", () => {
3200
+ const rep = makeRep(["hello"]);
3201
+ const { editorInfo, calls } = makeMockEditorInfo();
3202
+
3203
+ state.register = "abc";
3204
+ const ctx = {
3205
+ rep,
3206
+ editorInfo,
3207
+ line: 0,
3208
+ char: 0,
3209
+ lineText: "hello",
3210
+ count: 1,
3211
+ };
3212
+ commands.normal["p"](ctx);
3213
+
3214
+ const selects = calls.filter((c) => c.type === "select");
3215
+ const lastSelect = selects[selects.length - 1];
3216
+ assert.deepEqual(
3217
+ lastSelect.start,
3218
+ [0, 3],
3219
+ "cursor after p should be at last char of pasted text (insertPos + length - 1 = 1+3-1 = 3)",
3220
+ );
3221
+ });
3222
+
3223
+ it("p char-wise with count: cursor lands at last char of all pasted text", () => {
3224
+ const rep = makeRep(["hello"]);
3225
+ const { editorInfo, calls } = makeMockEditorInfo();
3226
+
3227
+ state.register = "ab";
3228
+ const ctx = {
3229
+ rep,
3230
+ editorInfo,
3231
+ line: 0,
3232
+ char: 0,
3233
+ lineText: "hello",
3234
+ count: 3,
3235
+ };
3236
+ commands.normal["p"](ctx);
3237
+
3238
+ const selects = calls.filter((c) => c.type === "select");
3239
+ const lastSelect = selects[selects.length - 1];
3240
+ assert.deepEqual(
3241
+ lastSelect.start,
3242
+ [0, 6],
3243
+ "cursor after 3p 'ab': insertPos=1, repeated='ababab' length=6, cursor=1+6-1=6",
3244
+ );
3245
+ });
3246
+ });
3247
+
3248
+ describe("register bug: P cursor at start of paste instead of end", () => {
3249
+ beforeEach(resetState);
3250
+
3251
+ it("P char-wise: cursor lands at last char of pasted text", () => {
3252
+ const rep = makeRep(["hello"]);
3253
+ const { editorInfo, calls } = makeMockEditorInfo();
3254
+
3255
+ state.register = "abc";
3256
+ const ctx = {
3257
+ rep,
3258
+ editorInfo,
3259
+ line: 0,
3260
+ char: 2,
3261
+ lineText: "hello",
3262
+ count: 1,
3263
+ };
3264
+ commands.normal["P"](ctx);
3265
+
3266
+ const selects = calls.filter((c) => c.type === "select");
3267
+ const lastSelect = selects[selects.length - 1];
3268
+ assert.deepEqual(
3269
+ lastSelect.start,
3270
+ [0, 4],
3271
+ "cursor after P should be at last char of pasted text (char + length - 1 = 2+3-1 = 4)",
3272
+ );
3273
+ });
3274
+
3275
+ it("P char-wise with count: cursor lands at last char of all pasted text", () => {
3276
+ const rep = makeRep(["hello"]);
3277
+ const { editorInfo, calls } = makeMockEditorInfo();
3278
+
3279
+ state.register = "ab";
3280
+ const ctx = {
3281
+ rep,
3282
+ editorInfo,
3283
+ line: 0,
3284
+ char: 1,
3285
+ lineText: "hello",
3286
+ count: 2,
3287
+ };
3288
+ commands.normal["P"](ctx);
3289
+
3290
+ const selects = calls.filter((c) => c.type === "select");
3291
+ const lastSelect = selects[selects.length - 1];
3292
+ assert.deepEqual(
3293
+ lastSelect.start,
3294
+ [0, 4],
3295
+ "cursor after 2P 'ab' should be at char + 4 - 1 = 1+4-1 = 4",
3296
+ );
3297
+ });
3298
+ });
3299
+
3300
+ describe("register bug: line-wise p cursor at col 0 instead of first non-blank", () => {
3301
+ beforeEach(resetState);
3302
+
3303
+ it("p line-wise: cursor lands on first non-blank of pasted line", () => {
3304
+ const rep = makeRep(["hello", "world"]);
3305
+ const { editorInfo, calls } = makeMockEditorInfo();
3306
+
3307
+ state.register = [" indented"];
3308
+ const ctx = {
3309
+ rep,
3310
+ editorInfo,
3311
+ line: 0,
3312
+ char: 0,
3313
+ lineText: "hello",
3314
+ count: 1,
3315
+ };
3316
+ commands.normal["p"](ctx);
3317
+
3318
+ const selects = calls.filter((c) => c.type === "select");
3319
+ const lastSelect = selects[selects.length - 1];
3320
+ assert.deepEqual(
3321
+ lastSelect.start,
3322
+ [1, 2],
3323
+ "p line-wise cursor should be at first non-blank (col 2) of pasted line",
3324
+ );
3325
+ });
3326
+
3327
+ it("p line-wise with unindented content: cursor at col 0", () => {
3328
+ const rep = makeRep(["hello", "world"]);
3329
+ const { editorInfo, calls } = makeMockEditorInfo();
3330
+
3331
+ state.register = ["noindent"];
3332
+ const ctx = {
3333
+ rep,
3334
+ editorInfo,
3335
+ line: 0,
3336
+ char: 0,
3337
+ lineText: "hello",
3338
+ count: 1,
3339
+ };
3340
+ commands.normal["p"](ctx);
3341
+
3342
+ const selects = calls.filter((c) => c.type === "select");
3343
+ const lastSelect = selects[selects.length - 1];
3344
+ assert.deepEqual(lastSelect.start, [1, 0]);
3345
+ });
3346
+ });
3347
+
3348
+ describe("register bug: line-wise P cursor at col 0 instead of first non-blank", () => {
3349
+ beforeEach(resetState);
3350
+
3351
+ it("P line-wise: cursor lands on first non-blank of pasted line", () => {
3352
+ const rep = makeRep(["hello", "world"]);
3353
+ const { editorInfo, calls } = makeMockEditorInfo();
3354
+
3355
+ state.register = [" indented"];
3356
+ const ctx = {
3357
+ rep,
3358
+ editorInfo,
3359
+ line: 1,
3360
+ char: 0,
3361
+ lineText: "world",
3362
+ count: 1,
3363
+ };
3364
+ commands.normal["P"](ctx);
3365
+
3366
+ const selects = calls.filter((c) => c.type === "select");
3367
+ const lastSelect = selects[selects.length - 1];
3368
+ assert.deepEqual(
3369
+ lastSelect.start,
3370
+ [1, 2],
3371
+ "P line-wise cursor should be at first non-blank (col 2) of pasted line",
3372
+ );
3373
+ });
3374
+ });
3375
+
3376
+ describe("register bug: Y missing recordCommand", () => {
3377
+ beforeEach(resetState);
3378
+
3379
+ it("Y sets lastCommand so dot can repeat it", () => {
3380
+ const rep = makeRep(["hello"]);
3381
+ const { editorInfo } = makeMockEditorInfo();
3382
+
3383
+ const ctx = {
3384
+ rep,
3385
+ editorInfo,
3386
+ line: 0,
3387
+ char: 0,
3388
+ lineText: "hello",
3389
+ count: 1,
3390
+ };
3391
+ commands.normal["Y"](ctx);
3392
+
3393
+ assert.notEqual(
3394
+ state.lastCommand,
3395
+ null,
3396
+ "Y should record command for dot-repeat",
3397
+ );
3398
+ assert.equal(state.lastCommand.key, "Y");
3399
+ });
3400
+
3401
+ it("Y does not corrupt lastCommand of a prior operation", () => {
3402
+ const rep = makeRep(["hello"]);
3403
+ const { editorInfo } = makeMockEditorInfo();
3404
+
3405
+ state.lastCommand = { key: "dd", count: 1, param: null };
3406
+ const ctx = {
3407
+ rep,
3408
+ editorInfo,
3409
+ line: 0,
3410
+ char: 0,
3411
+ lineText: "hello",
3412
+ count: 1,
3413
+ };
3414
+ commands.normal["Y"](ctx);
3415
+
3416
+ assert.equal(
3417
+ state.lastCommand.key,
3418
+ "Y",
3419
+ "Y should overwrite lastCommand with its own entry",
3420
+ );
3421
+ });
3422
+ });
3423
+
3424
+ describe("missing feature: named registers", () => {
3425
+ beforeEach(resetState);
3426
+
3427
+ it('"ayy yanks into named register a', () => {
3428
+ // In vim, "a followed by yy copies the line into register 'a'.
3429
+ // This requires parsing the " prefix in handleKey and routing
3430
+ // setRegister calls to the named register slot.
3431
+ const rep = makeRep(["hello"]);
3432
+ const { editorInfo } = makeMockEditorInfo();
3433
+ const ctx = {
3434
+ rep,
3435
+ editorInfo,
3436
+ line: 0,
3437
+ char: 0,
3438
+ lineText: "hello",
3439
+ count: 1,
3440
+ hasCount: false,
3441
+ };
3442
+
3443
+ // Simulate: " -> a -> yy
3444
+ handleKey('"', ctx);
3445
+ handleKey("a", ctx);
3446
+ handleKey("y", ctx);
3447
+ handleKey("y", ctx);
3448
+
3449
+ assert.ok(
3450
+ state.namedRegisters && state.namedRegisters["a"],
3451
+ "register a should be set",
3452
+ );
3453
+ assert.deepEqual(state.namedRegisters["a"], ["hello"]);
3454
+ });
3455
+
3456
+ it('"ap pastes from named register a', () => {
3457
+ state.namedRegisters = { a: ["yanked line"] };
3458
+ const rep = makeRep(["hello"]);
3459
+ const { editorInfo, calls } = makeMockEditorInfo();
3460
+ const ctx = {
3461
+ rep,
3462
+ editorInfo,
3463
+ line: 0,
3464
+ char: 0,
3465
+ lineText: "hello",
3466
+ count: 1,
3467
+ hasCount: false,
3468
+ };
3469
+
3470
+ // Simulate: " -> a -> p
3471
+ handleKey('"', ctx);
3472
+ handleKey("a", ctx);
3473
+ handleKey("p", ctx);
3474
+
3475
+ const replaces = calls.filter((c) => c.type === "replace");
3476
+ assert.ok(replaces.length > 0, "should paste from named register");
3477
+ });
3478
+
3479
+ it('"add deletes line into named register a', () => {
3480
+ const rep = makeRep(["hello", "world"]);
3481
+ const { editorInfo } = makeMockEditorInfo();
3482
+ const ctx = {
3483
+ rep,
3484
+ editorInfo,
3485
+ line: 0,
3486
+ char: 0,
3487
+ lineText: "hello",
3488
+ count: 1,
3489
+ hasCount: false,
3490
+ };
3491
+
3492
+ handleKey('"', ctx);
3493
+ handleKey("a", ctx);
3494
+ handleKey("d", ctx);
3495
+ handleKey("d", ctx);
3496
+
3497
+ assert.ok(
3498
+ state.namedRegisters && state.namedRegisters["a"],
3499
+ "register a should be set after delete",
3500
+ );
3501
+ assert.deepEqual(state.namedRegisters["a"], ["hello"]);
3502
+ assert.equal(state.register, null, "anonymous register should not be set");
3503
+ });
3504
+
3505
+ it('"ayw yanks word into named register a', () => {
3506
+ const rep = makeRep(["hello world"]);
3507
+ const { editorInfo } = makeMockEditorInfo();
3508
+ const ctx = {
3509
+ rep,
3510
+ editorInfo,
3511
+ line: 0,
3512
+ char: 0,
3513
+ lineText: "hello world",
3514
+ count: 1,
3515
+ hasCount: false,
3516
+ };
3517
+
3518
+ handleKey('"', ctx);
3519
+ handleKey("a", ctx);
3520
+ handleKey("y", ctx);
3521
+ handleKey("w", ctx);
3522
+
3523
+ assert.deepEqual(state.namedRegisters["a"], "hello ");
3524
+ });
3525
+
3526
+ it("named registers are independent", () => {
3527
+ const rep = makeRep(["first", "second"]);
3528
+ const { editorInfo } = makeMockEditorInfo();
3529
+
3530
+ const ctx0 = {
3531
+ rep,
3532
+ editorInfo,
3533
+ line: 0,
3534
+ char: 0,
3535
+ lineText: "first",
3536
+ count: 1,
3537
+ hasCount: false,
3538
+ };
3539
+ handleKey('"', ctx0);
3540
+ handleKey("a", ctx0);
3541
+ handleKey("y", ctx0);
3542
+ handleKey("y", ctx0);
3543
+
3544
+ const ctx1 = {
3545
+ rep,
3546
+ editorInfo,
3547
+ line: 1,
3548
+ char: 0,
3549
+ lineText: "second",
3550
+ count: 1,
3551
+ hasCount: false,
3552
+ };
3553
+ handleKey('"', ctx1);
3554
+ handleKey("b", ctx1);
3555
+ handleKey("y", ctx1);
3556
+ handleKey("y", ctx1);
3557
+
3558
+ assert.deepEqual(state.namedRegisters["a"], ["first"]);
3559
+ assert.deepEqual(state.namedRegisters["b"], ["second"]);
3560
+ });
3561
+
3562
+ it('"_yy yank to blackhole does not affect anonymous register', () => {
3563
+ state.register = "preserved";
3564
+ const rep = makeRep(["hello"]);
3565
+ const { editorInfo } = makeMockEditorInfo();
3566
+ const ctx = {
3567
+ rep,
3568
+ editorInfo,
3569
+ line: 0,
3570
+ char: 0,
3571
+ lineText: "hello",
3572
+ count: 1,
3573
+ hasCount: false,
3574
+ };
3575
+
3576
+ handleKey('"', ctx);
3577
+ handleKey("_", ctx);
3578
+ handleKey("y", ctx);
3579
+ handleKey("y", ctx);
3580
+
3581
+ assert.equal(state.register, "preserved", "anonymous register unchanged");
3582
+ });
3583
+
3584
+ it('"_p pastes nothing from blackhole register', () => {
3585
+ state.register = "fallback";
3586
+ const rep = makeRep(["hello"]);
3587
+ const { editorInfo, calls } = makeMockEditorInfo();
3588
+ const ctx = {
3589
+ rep,
3590
+ editorInfo,
3591
+ line: 0,
3592
+ char: 0,
3593
+ lineText: "hello",
3594
+ count: 1,
3595
+ hasCount: false,
3596
+ };
3597
+
3598
+ handleKey('"', ctx);
3599
+ handleKey("_", ctx);
3600
+ handleKey("p", ctx);
3601
+
3602
+ const replaces = calls.filter((c) => c.type === "replace");
3603
+ assert.equal(replaces.length, 0, "blackhole p should paste nothing");
3604
+ });
3605
+
3606
+ it('"_dd deletes to blackhole register without overwriting anonymous', () => {
3607
+ state.register = "preserved";
3608
+ const rep = makeRep(["hello", "world"]);
3609
+ const { editorInfo } = makeMockEditorInfo();
3610
+ const ctx = {
3611
+ rep,
3612
+ editorInfo,
3613
+ line: 0,
3614
+ char: 0,
3615
+ lineText: "hello",
3616
+ count: 1,
3617
+ hasCount: false,
3618
+ };
3619
+
3620
+ // Simulate: " -> _ -> dd
3621
+ handleKey('"', ctx);
3622
+ handleKey("_", ctx);
3623
+ handleKey("d", ctx);
3624
+ handleKey("d", ctx);
3625
+
3626
+ assert.equal(
3627
+ state.register,
3628
+ "preserved",
3629
+ "blackhole register should not overwrite anonymous register",
3630
+ );
3631
+ });
3632
+ });
3633
+
3634
+ describe("missing feature: * and # word search", () => {
3635
+ beforeEach(resetState);
3636
+
3637
+ it("* searches forward for word under cursor", () => {
3638
+ const rep = makeRep(["hello world hello"]);
3639
+ const { editorInfo, calls } = makeMockEditorInfo();
3640
+ const ctx = {
3641
+ rep,
3642
+ editorInfo,
3643
+ line: 0,
3644
+ char: 0,
3645
+ lineText: "hello world hello",
3646
+ count: 1,
3647
+ hasCount: false,
3648
+ };
3649
+
3650
+ commands.normal["*"](ctx);
3651
+
3652
+ assert.equal(calls.length, 1, "should move cursor to next match");
3653
+ assert.deepEqual(state.lastSearch, { pattern: "hello", direction: "/" });
3654
+ });
3655
+
3656
+ it("* on non-word char does nothing", () => {
3657
+ const rep = makeRep(["hello world"]);
3658
+ const { editorInfo, calls } = makeMockEditorInfo();
3659
+ const ctx = {
3660
+ rep,
3661
+ editorInfo,
3662
+ line: 0,
3663
+ char: 5,
3664
+ lineText: "hello world",
3665
+ count: 1,
3666
+ hasCount: false,
3667
+ };
3668
+
3669
+ commands.normal["*"](ctx);
3670
+
3671
+ assert.equal(calls.length, 0, "should not move cursor");
3672
+ assert.equal(state.lastSearch, null);
3673
+ });
3674
+
3675
+ it("# searches backward for word under cursor", () => {
3676
+ const rep = makeRep(["hello world hello"]);
3677
+ const { editorInfo, calls } = makeMockEditorInfo();
3678
+ const ctx = {
3679
+ rep,
3680
+ editorInfo,
3681
+ line: 0,
3682
+ char: 12,
3683
+ lineText: "hello world hello",
3684
+ count: 1,
3685
+ hasCount: false,
3686
+ };
3687
+
3688
+ commands.normal["#"](ctx);
3689
+
3690
+ assert.equal(calls.length, 1, "should move cursor to previous match");
3691
+ assert.deepEqual(state.lastSearch, { pattern: "hello", direction: "?" });
3692
+ });
3693
+
3694
+ it("* sets lastSearch so n repeats the search", () => {
3695
+ const rep = makeRep(["foo bar foo bar"]);
3696
+ const { editorInfo } = makeMockEditorInfo();
3697
+ const ctx = {
3698
+ rep,
3699
+ editorInfo,
3700
+ line: 0,
3701
+ char: 0,
3702
+ lineText: "foo bar foo bar",
3703
+ count: 1,
3704
+ hasCount: false,
3705
+ };
3706
+
3707
+ commands.normal["*"](ctx);
3708
+
3709
+ assert.deepEqual(state.lastSearch, { pattern: "foo", direction: "/" });
3710
+ });
3711
+ });
3712
+
3713
+ describe("missing feature: zz center screen", () => {
3714
+ beforeEach(resetState);
3715
+
3716
+ it("zz does nothing when editorDoc is null", () => {
3717
+ state.editorDoc = null;
3718
+ const rep = makeRep(["hello"]);
3719
+ const { editorInfo } = makeMockEditorInfo();
3720
+ const ctx = {
3721
+ rep,
3722
+ editorInfo,
3723
+ line: 0,
3724
+ char: 0,
3725
+ lineText: "hello",
3726
+ count: 1,
3727
+ hasCount: false,
3728
+ };
3729
+
3730
+ assert.doesNotThrow(() => commands.normal["zz"](ctx));
3731
+ });
3732
+ });
@@ -0,0 +1,8 @@
1
+ <p>
2
+ <input type="checkbox" id="options-vim-use-system-clipboard" checked></input>
3
+ <label for="options-vim-use-system-clipboard">Use system clipboard (vim)</label>
4
+ </p>
5
+ <p>
6
+ <input type="checkbox" id="options-vim-use-ctrl-keys" checked></input>
7
+ <label for="options-vim-use-ctrl-keys">Use Ctrl keys (vim)</label>
8
+ </p>