ep_vim 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/package.json +2 -2
- package/static/js/index.js +68 -6
- package/static/js/insert.test.js +198 -0
- package/static/js/misc.test.js +363 -0
- package/static/js/motions.test.js +1377 -0
- package/static/js/operators.test.js +1118 -0
- package/static/js/paste_registers.test.js +778 -0
- package/static/js/search.test.js +234 -0
- package/static/js/visual.test.js +382 -0
- package/static/js/index.test.js +0 -4023
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
|
|
|
19
19
|
- **Line operations** — `dd`, `cc`, `yy`, `D`, `J` (join), `Y` (yank line)
|
|
20
20
|
- **Registers** — `"a`–`"z` named registers for yank/delete/put, `"_` blackhole register
|
|
21
21
|
- **Put** — `p` / `P` with linewise and characterwise register handling
|
|
22
|
-
- **Editing** — `i` `a` `A` `I` (insert/append), `x`, `r`, `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
|
|
22
|
+
- **Editing** — `i` `a` `A` `I` (insert/append), `x`, `X`, `r`, `R` (replace mode), `s`, `S`, `C`, `o`, `O`, `~` (toggle case)
|
|
23
23
|
- **Marks** — `m{a-z}` to set, `'{a-z}` / `` `{a-z} `` to jump
|
|
24
24
|
- **Search** — `/` and `?` forward/backward, `n`/`N` repeat, `*`/`#` search word under cursor
|
|
25
25
|
- **Scrolling** — `zz`/`zt`/`zb` center/top/bottom, `Ctrl+d`/`Ctrl+u` half-page, `Ctrl+f`/`Ctrl+b` full-page (requires ctrl keys enabled)
|
|
@@ -30,8 +30,12 @@ A vim-mode plugin for [Etherpad](https://etherpad.org/). Adds modal editing with
|
|
|
30
30
|
- **Toggle** — toolbar button to enable/disable vim mode, persisted in localStorage; settings panel for system clipboard and ctrl key behavior
|
|
31
31
|
|
|
32
32
|
## Differences from vi
|
|
33
|
+
The following are not planned, but PRs are welcome.
|
|
33
34
|
|
|
34
|
-
- **No command line, macros, or globals**
|
|
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.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Vim-mode plugin for Etherpad with modal editing, motions, and operators",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Seth Rothschild",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"ep_etherpad-lite": ">=1.8.6"
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
|
-
"test": "npm run format && node --test static/js/vim-core.test.js static/js/
|
|
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": {
|
package/static/js/index.js
CHANGED
|
@@ -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,
|
|
709
|
-
state.visualCursor = [line,
|
|
707
|
+
commands.normal["V"] = ({ editorInfo, rep, line, char }) => {
|
|
708
|
+
state.visualAnchor = [line, char];
|
|
709
|
+
state.visualCursor = [line, char];
|
|
710
710
|
state.mode = "visual-line";
|
|
711
711
|
updateVisualSelection(editorInfo, rep);
|
|
712
712
|
};
|
|
@@ -786,6 +786,16 @@ commands["visual-line"]["a"] = () => {
|
|
|
786
786
|
state.pendingKey = "a";
|
|
787
787
|
};
|
|
788
788
|
|
|
789
|
+
const swapVisualEnds = ({ editorInfo, rep }) => {
|
|
790
|
+
const tmp = state.visualAnchor;
|
|
791
|
+
state.visualAnchor = state.visualCursor;
|
|
792
|
+
state.visualCursor = tmp;
|
|
793
|
+
updateVisualSelection(editorInfo, rep);
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
commands["visual-char"]["o"] = swapVisualEnds;
|
|
797
|
+
commands["visual-line"]["o"] = swapVisualEnds;
|
|
798
|
+
|
|
789
799
|
commands.normal["gv"] = ({ editorInfo, rep }) => {
|
|
790
800
|
if (!state.lastVisualSelection) return;
|
|
791
801
|
const { anchor, cursor, mode } = state.lastVisualSelection;
|
|
@@ -840,6 +850,12 @@ commands.normal["O"] = ({ editorInfo, line }) => {
|
|
|
840
850
|
state.mode = "insert";
|
|
841
851
|
};
|
|
842
852
|
|
|
853
|
+
commands.normal["R"] = ({ editorInfo, line, char }) => {
|
|
854
|
+
clearEmptyLineCursor();
|
|
855
|
+
moveCursor(editorInfo, line, char);
|
|
856
|
+
state.mode = "replace";
|
|
857
|
+
};
|
|
858
|
+
|
|
843
859
|
// --- Editing commands ---
|
|
844
860
|
|
|
845
861
|
commands.normal["r"] = () => {
|
|
@@ -869,6 +885,18 @@ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
|
|
|
869
885
|
}
|
|
870
886
|
};
|
|
871
887
|
|
|
888
|
+
commands.normal["X"] = ({ editorInfo, rep, line, char, lineText, count }) => {
|
|
889
|
+
if (char > 0) {
|
|
890
|
+
const deleteCount = Math.min(count, char);
|
|
891
|
+
const startChar = char - deleteCount;
|
|
892
|
+
setRegister(lineText.slice(startChar, char));
|
|
893
|
+
replaceRange(editorInfo, [line, startChar], [line, char], "");
|
|
894
|
+
const newLineText = getLineText(rep, line);
|
|
895
|
+
moveBlockCursor(editorInfo, line, clampChar(startChar, newLineText));
|
|
896
|
+
recordCommand("X", count);
|
|
897
|
+
}
|
|
898
|
+
};
|
|
899
|
+
|
|
872
900
|
commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
|
|
873
901
|
const reg = getActiveRegister();
|
|
874
902
|
if (reg !== null) {
|
|
@@ -1290,9 +1318,9 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
|
|
|
1290
1318
|
cursor: state.visualCursor,
|
|
1291
1319
|
mode: "visual-line",
|
|
1292
1320
|
};
|
|
1293
|
-
const
|
|
1321
|
+
const [vLine, vChar] = state.visualCursor;
|
|
1294
1322
|
state.mode = "normal";
|
|
1295
|
-
moveBlockCursor(editorInfo,
|
|
1323
|
+
moveBlockCursor(editorInfo, vLine, vChar);
|
|
1296
1324
|
} else if (state.mode === "visual-char") {
|
|
1297
1325
|
state.lastVisualSelection = {
|
|
1298
1326
|
anchor: state.visualAnchor,
|
|
@@ -1302,7 +1330,7 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
|
|
|
1302
1330
|
const [vLine, vChar] = state.visualCursor;
|
|
1303
1331
|
state.mode = "normal";
|
|
1304
1332
|
moveBlockCursor(editorInfo, vLine, vChar);
|
|
1305
|
-
} else if (state.mode === "insert") {
|
|
1333
|
+
} else if (state.mode === "insert" || state.mode === "replace") {
|
|
1306
1334
|
state.mode = "normal";
|
|
1307
1335
|
const [curLine, curChar] = rep.selStart;
|
|
1308
1336
|
moveBlockCursor(editorInfo, curLine, Math.max(0, curChar - 1));
|
|
@@ -1320,6 +1348,40 @@ exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
|
|
|
1320
1348
|
|
|
1321
1349
|
if (state.mode === "insert") return false;
|
|
1322
1350
|
|
|
1351
|
+
if (state.mode === "replace") {
|
|
1352
|
+
if (evt.key === "Backspace") {
|
|
1353
|
+
const [curLine, curChar] = rep.selStart;
|
|
1354
|
+
if (curChar > 0) {
|
|
1355
|
+
moveCursor(editorInfo, curLine, curChar - 1);
|
|
1356
|
+
}
|
|
1357
|
+
evt.preventDefault();
|
|
1358
|
+
return true;
|
|
1359
|
+
}
|
|
1360
|
+
if (evt.key.length === 1 && !evt.ctrlKey && !evt.metaKey) {
|
|
1361
|
+
const [curLine, curChar] = rep.selStart;
|
|
1362
|
+
const curLineText = rep.lines.atIndex(curLine).text;
|
|
1363
|
+
if (curChar < curLineText.length) {
|
|
1364
|
+
replaceRange(
|
|
1365
|
+
editorInfo,
|
|
1366
|
+
[curLine, curChar],
|
|
1367
|
+
[curLine, curChar + 1],
|
|
1368
|
+
evt.key,
|
|
1369
|
+
);
|
|
1370
|
+
} else {
|
|
1371
|
+
replaceRange(
|
|
1372
|
+
editorInfo,
|
|
1373
|
+
[curLine, curChar],
|
|
1374
|
+
[curLine, curChar],
|
|
1375
|
+
evt.key,
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
moveCursor(editorInfo, curLine, curChar + 1);
|
|
1379
|
+
evt.preventDefault();
|
|
1380
|
+
return true;
|
|
1381
|
+
}
|
|
1382
|
+
return false;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1323
1385
|
if (state.searchMode) {
|
|
1324
1386
|
if (evt.key === "Enter") {
|
|
1325
1387
|
state.searchMode = false;
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { describe, it, beforeEach } = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
|
|
6
|
+
// Mock navigator for clipboard operations
|
|
7
|
+
global.navigator = {
|
|
8
|
+
clipboard: {
|
|
9
|
+
writeText: () => Promise.resolve(),
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
_state: state,
|
|
15
|
+
_handleKey: handleKey,
|
|
16
|
+
_commands: commands,
|
|
17
|
+
_parameterized: parameterized,
|
|
18
|
+
_setVimEnabled: setVimEnabled,
|
|
19
|
+
_setUseCtrlKeys: setUseCtrlKeys,
|
|
20
|
+
aceKeyEvent,
|
|
21
|
+
} = require("./index.js");
|
|
22
|
+
|
|
23
|
+
const makeRep = (lines) => ({
|
|
24
|
+
lines: {
|
|
25
|
+
length: () => lines.length,
|
|
26
|
+
atIndex: (n) => ({ text: lines[n] }),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const makeMockEditorInfo = () => {
|
|
31
|
+
const calls = [];
|
|
32
|
+
return {
|
|
33
|
+
editorInfo: {
|
|
34
|
+
ace_inCallStackIfNecessary: (_name, fn) => fn(),
|
|
35
|
+
ace_performSelectionChange: (start, end, _flag) => {
|
|
36
|
+
calls.push({ type: "select", start, end });
|
|
37
|
+
},
|
|
38
|
+
ace_updateBrowserSelectionFromRep: () => {},
|
|
39
|
+
ace_performDocumentReplaceRange: (start, end, newText) => {
|
|
40
|
+
calls.push({ type: "replace", start, end, newText });
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
calls,
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
const resetState = () => {
|
|
50
|
+
state.mode = "normal";
|
|
51
|
+
state.pendingKey = null;
|
|
52
|
+
state.pendingCount = null;
|
|
53
|
+
state.countBuffer = "";
|
|
54
|
+
state.register = null;
|
|
55
|
+
state.namedRegisters = {};
|
|
56
|
+
state.pendingRegister = null;
|
|
57
|
+
state.awaitingRegister = false;
|
|
58
|
+
state.marks = {};
|
|
59
|
+
state.lastCharSearch = null;
|
|
60
|
+
state.visualAnchor = null;
|
|
61
|
+
state.visualCursor = null;
|
|
62
|
+
state.editorDoc = null;
|
|
63
|
+
state.currentRep = null;
|
|
64
|
+
state.desiredColumn = null;
|
|
65
|
+
state.lastCommand = null;
|
|
66
|
+
state.searchMode = false;
|
|
67
|
+
state.searchBuffer = "";
|
|
68
|
+
state.searchDirection = null;
|
|
69
|
+
state.lastSearch = null;
|
|
70
|
+
state.lastVisualSelection = null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe("insert mode commands", () => {
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
state.mode = "normal";
|
|
76
|
+
state.pendingKey = null;
|
|
77
|
+
state.pendingCount = null;
|
|
78
|
+
state.countBuffer = "";
|
|
79
|
+
state.register = null;
|
|
80
|
+
state.marks = {};
|
|
81
|
+
state.lastCharSearch = null;
|
|
82
|
+
state.visualAnchor = null;
|
|
83
|
+
state.visualCursor = null;
|
|
84
|
+
state.editorDoc = null;
|
|
85
|
+
state.currentRep = null;
|
|
86
|
+
state.desiredColumn = null;
|
|
87
|
+
state.lastCommand = null;
|
|
88
|
+
state.searchMode = false;
|
|
89
|
+
state.searchBuffer = "";
|
|
90
|
+
state.searchDirection = null;
|
|
91
|
+
state.lastSearch = null;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("i enters insert mode at cursor", () => {
|
|
95
|
+
const rep = makeRep(["hello"]);
|
|
96
|
+
const { editorInfo } = makeMockEditorInfo();
|
|
97
|
+
|
|
98
|
+
const ctx = { rep, editorInfo, line: 0, char: 2, lineText: "hello" };
|
|
99
|
+
commands.normal["i"](ctx);
|
|
100
|
+
|
|
101
|
+
assert.equal(state.mode, "insert");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("a enters insert mode after cursor", () => {
|
|
105
|
+
const rep = makeRep(["hello"]);
|
|
106
|
+
const { editorInfo } = makeMockEditorInfo();
|
|
107
|
+
|
|
108
|
+
const ctx = { rep, editorInfo, line: 0, char: 2, lineText: "hello" };
|
|
109
|
+
commands.normal["a"](ctx);
|
|
110
|
+
|
|
111
|
+
assert.equal(state.mode, "insert");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("A enters insert mode at end of line", () => {
|
|
115
|
+
const rep = makeRep(["hello"]);
|
|
116
|
+
const { editorInfo } = makeMockEditorInfo();
|
|
117
|
+
|
|
118
|
+
const ctx = { rep, editorInfo, line: 0, char: 2, lineText: "hello" };
|
|
119
|
+
commands.normal["A"](ctx);
|
|
120
|
+
|
|
121
|
+
assert.equal(state.mode, "insert");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("I enters insert mode at first non-blank", () => {
|
|
125
|
+
const rep = makeRep([" hello"]);
|
|
126
|
+
const { editorInfo } = makeMockEditorInfo();
|
|
127
|
+
|
|
128
|
+
const ctx = { rep, editorInfo, line: 0, char: 2, lineText: " hello" };
|
|
129
|
+
commands.normal["I"](ctx);
|
|
130
|
+
|
|
131
|
+
assert.equal(state.mode, "insert");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("o opens line below", () => {
|
|
135
|
+
const rep = makeRep(["hello"]);
|
|
136
|
+
const { editorInfo, calls } = makeMockEditorInfo();
|
|
137
|
+
|
|
138
|
+
const ctx = { rep, editorInfo, line: 0, char: 0, lineText: "hello" };
|
|
139
|
+
commands.normal["o"](ctx);
|
|
140
|
+
|
|
141
|
+
assert.equal(state.mode, "insert");
|
|
142
|
+
assert.equal(calls.length, 2);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("O opens line above", () => {
|
|
146
|
+
const rep = makeRep(["hello"]);
|
|
147
|
+
const { editorInfo, calls } = makeMockEditorInfo();
|
|
148
|
+
|
|
149
|
+
const ctx = { rep, editorInfo, line: 0, char: 0, lineText: "hello" };
|
|
150
|
+
commands.normal["O"](ctx);
|
|
151
|
+
|
|
152
|
+
assert.equal(state.mode, "insert");
|
|
153
|
+
assert.equal(calls.length, 2);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("s replaces character and enters insert", () => {
|
|
157
|
+
const rep = makeRep(["hello"]);
|
|
158
|
+
const { editorInfo } = makeMockEditorInfo();
|
|
159
|
+
|
|
160
|
+
const ctx = {
|
|
161
|
+
rep,
|
|
162
|
+
editorInfo,
|
|
163
|
+
line: 0,
|
|
164
|
+
char: 1,
|
|
165
|
+
lineText: "hello",
|
|
166
|
+
count: 1,
|
|
167
|
+
};
|
|
168
|
+
commands.normal["s"](ctx);
|
|
169
|
+
|
|
170
|
+
assert.equal(state.mode, "insert");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("S replaces entire line and enters insert", () => {
|
|
174
|
+
const rep = makeRep(["hello"]);
|
|
175
|
+
const { editorInfo } = makeMockEditorInfo();
|
|
176
|
+
|
|
177
|
+
const ctx = { rep, editorInfo, line: 0, char: 2, lineText: "hello" };
|
|
178
|
+
commands.normal["S"](ctx);
|
|
179
|
+
|
|
180
|
+
assert.equal(state.mode, "insert");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("C changes to end of line and enters insert", () => {
|
|
184
|
+
const rep = makeRep(["hello world"]);
|
|
185
|
+
const { editorInfo } = makeMockEditorInfo();
|
|
186
|
+
|
|
187
|
+
const ctx = {
|
|
188
|
+
rep,
|
|
189
|
+
editorInfo,
|
|
190
|
+
line: 0,
|
|
191
|
+
char: 6,
|
|
192
|
+
lineText: "hello world",
|
|
193
|
+
};
|
|
194
|
+
commands.normal["C"](ctx);
|
|
195
|
+
|
|
196
|
+
assert.equal(state.mode, "insert");
|
|
197
|
+
});
|
|
198
|
+
});
|