ep_vim 0.6.1 → 0.9.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.
@@ -9,64 +9,53 @@ const {
9
9
  wordBackward,
10
10
  wordEnd,
11
11
  charSearchPos,
12
- motionRange,
13
12
  charMotionRange,
13
+ paragraphForward,
14
+ paragraphBackward,
15
+ matchingBracketPos,
14
16
  textWordRange,
15
17
  textQuoteRange,
16
18
  textBracketRange,
17
- getVisualSelection,
18
- paragraphForward,
19
- paragraphBackward,
20
19
  getTextInRange,
20
+ getVisualSelection,
21
+ paragraphTextRange,
22
+ sentenceTextRange,
23
+ getFullText,
24
+ posToAbsolute,
25
+ absoluteToPos,
26
+ searchForward,
27
+ searchBackward,
21
28
  } = require("./vim-core");
22
29
 
23
- // --- State variables ---
30
+ // --- State ---
24
31
 
25
32
  let vimEnabled = localStorage.getItem("ep_vimEnabled") === "true";
26
- let insertMode = false;
27
- let visualMode = null;
28
- let visualAnchor = null;
29
- let visualCursor = null;
33
+ let mode = "normal";
30
34
  let pendingKey = null;
31
- let pendingOperator = null;
32
35
  let pendingCount = null;
33
36
  let countBuffer = "";
34
37
  let register = null;
35
38
  let marks = {};
39
+ let lastCharSearch = null;
40
+ let visualAnchor = null;
41
+ let visualCursor = null;
36
42
  let editorDoc = null;
37
43
  let currentRep = null;
38
44
  let desiredColumn = null;
39
- let lastCharSearch = null;
40
-
41
- const QUOTE_CHARS = new Set(['"', "'"]);
42
- const BRACKET_CHARS = new Set(["(", ")", "{", "}", "[", "]"]);
43
-
44
- const textObjectRange = (key, lineText, char, type) => {
45
- if (key === "w") return textWordRange(lineText, char, type);
46
- if (QUOTE_CHARS.has(key)) return textQuoteRange(lineText, char, key, type);
47
- if (BRACKET_CHARS.has(key))
48
- return textBracketRange(lineText, char, key, type);
49
- };
45
+ let lastCommand = null;
46
+ let searchMode = false;
47
+ let searchBuffer = "";
48
+ let searchDirection = null;
49
+ let lastSearch = null;
50
50
 
51
- // --- Count helpers ---
52
-
53
- const consumeCount = () => {
54
- if (countBuffer !== "") {
55
- pendingCount = parseInt(countBuffer, 10);
56
- countBuffer = "";
57
- } else if (pendingKey === null && pendingOperator === null) {
58
- pendingCount = null;
59
- }
60
- };
61
-
62
- const getCount = () => pendingCount || 1;
63
-
64
- // --- Side-effectful helpers ---
51
+ // --- Editor operations ---
65
52
 
66
53
  const setRegister = (value) => {
67
54
  register = value;
68
55
  const text = Array.isArray(value) ? value.join("\n") + "\n" : value;
69
- navigator.clipboard.writeText(text).catch(() => {});
56
+ if (navigator.clipboard) {
57
+ navigator.clipboard.writeText(text).catch(() => {});
58
+ }
70
59
  };
71
60
 
72
61
  const moveCursor = (editorInfo, line, char) => {
@@ -77,22 +66,10 @@ const moveCursor = (editorInfo, line, char) => {
77
66
  });
78
67
  };
79
68
 
80
- const clearEmptyLineCursor = () => {
81
- if (!editorDoc) return;
82
- const old = editorDoc.querySelector(".vim-empty-line-cursor");
83
- if (old) old.classList.remove("vim-empty-line-cursor");
84
- };
85
-
86
- const moveBlockCursor = (editorInfo, line, char) => {
87
- clearEmptyLineCursor();
88
- const lineText = currentRep ? getLineText(currentRep, line) : "";
89
- if (lineText.length === 0 && editorDoc) {
90
- const lineDiv = editorDoc.body.querySelectorAll("div")[line];
91
- if (lineDiv) lineDiv.classList.add("vim-empty-line-cursor");
92
- selectRange(editorInfo, [line, 0], [line, 0]);
93
- } else {
94
- selectRange(editorInfo, [line, char], [line, char + 1]);
95
- }
69
+ const replaceRange = (editorInfo, start, end, text) => {
70
+ editorInfo.ace_inCallStackIfNecessary("vim-edit", () => {
71
+ editorInfo.ace_performDocumentReplaceRange(start, end, text);
72
+ });
96
73
  };
97
74
 
98
75
  const selectRange = (editorInfo, start, end) => {
@@ -102,37 +79,10 @@ const selectRange = (editorInfo, start, end) => {
102
79
  });
103
80
  };
104
81
 
105
- const replaceRange = (editorInfo, start, end, text) => {
106
- editorInfo.ace_inCallStackIfNecessary("vim-edit", () => {
107
- editorInfo.ace_performDocumentReplaceRange(start, end, text);
108
- });
109
- };
110
-
111
- const undo = (editorInfo) => {
112
- editorInfo.ace_doUndoRedo("undo");
113
- };
114
-
115
- // --- Mode management ---
116
-
117
- const setInsertMode = (value) => {
118
- insertMode = value;
119
- if (value) clearEmptyLineCursor();
120
- if (editorDoc) {
121
- editorDoc.body.classList.toggle("vim-insert-mode", value);
122
- }
123
- };
124
-
125
- const setVisualMode = (value) => {
126
- visualMode = value;
127
- if (editorDoc) {
128
- editorDoc.body.classList.toggle("vim-visual-line-mode", value === "line");
129
- editorDoc.body.classList.toggle("vim-visual-char-mode", value === "char");
130
- }
131
- };
132
-
133
82
  const updateVisualSelection = (editorInfo, rep) => {
83
+ const vMode = mode === "visual-line" ? "line" : "char";
134
84
  const [start, end] = getVisualSelection(
135
- visualMode,
85
+ vMode,
136
86
  visualAnchor,
137
87
  visualCursor,
138
88
  rep,
@@ -140,201 +90,38 @@ const updateVisualSelection = (editorInfo, rep) => {
140
90
  selectRange(editorInfo, start, end);
141
91
  };
142
92
 
143
- // --- Motion resolution (shared between normal and visual) ---
144
-
145
- const resolveMotion = (key, line, char, lineText, rep, count) => {
146
- if (
147
- pendingKey === "f" ||
148
- pendingKey === "F" ||
149
- pendingKey === "t" ||
150
- pendingKey === "T"
151
- ) {
152
- const direction = pendingKey;
153
- pendingKey = null;
154
- lastCharSearch = { direction, target: key };
155
- const pos = charSearchPos(direction, lineText, char, key, count);
156
- if (pos !== -1) {
157
- desiredColumn = null;
158
- return { line, char: pos };
159
- }
160
- return { line, char };
161
- }
162
-
163
- if (pendingKey === "'" || pendingKey === "`") {
164
- const jumpType = pendingKey;
165
- pendingKey = null;
166
- if (key >= "a" && key <= "z" && marks[key]) {
167
- const [markLine, markChar] = marks[key];
168
- desiredColumn = null;
169
- if (jumpType === "'") {
170
- const targetLineText = getLineText(rep, markLine);
171
- return { line: markLine, char: firstNonBlank(targetLineText) };
172
- }
173
- return { line: markLine, char: markChar };
174
- }
175
- return { line, char };
176
- }
177
-
178
- if (pendingKey === "g") {
179
- pendingKey = null;
180
- if (key === "g") {
181
- desiredColumn = null;
182
- if (pendingCount !== null) {
183
- return { line: clampLine(pendingCount - 1, rep), char: 0 };
184
- }
185
- return { line: 0, char: 0 };
186
- }
187
- }
188
-
189
- if (key === "h") {
190
- desiredColumn = null;
191
- return { line, char: Math.max(0, char - count) };
192
- }
193
-
194
- if (key === "l") {
195
- desiredColumn = null;
196
- return { line, char: clampChar(char + count, lineText) };
197
- }
198
-
199
- if (key === "j") {
200
- if (desiredColumn === null) desiredColumn = char;
201
- const newLine = clampLine(line + count, rep);
202
- const newLineText = getLineText(rep, newLine);
203
- return { line: newLine, char: clampChar(desiredColumn, newLineText) };
204
- }
205
-
206
- if (key === "k") {
207
- if (desiredColumn === null) desiredColumn = char;
208
- const newLine = clampLine(line - count, rep);
209
- const newLineText = getLineText(rep, newLine);
210
- return { line: newLine, char: clampChar(desiredColumn, newLineText) };
211
- }
212
-
213
- if (key === "w") {
214
- desiredColumn = null;
215
- let pos = char;
216
- for (let i = 0; i < count; i++) pos = wordForward(lineText, pos);
217
- return { line, char: clampChar(pos, lineText) };
218
- }
219
-
220
- if (key === "b") {
221
- desiredColumn = null;
222
- let pos = char;
223
- for (let i = 0; i < count; i++) pos = wordBackward(lineText, pos);
224
- return { line, char: pos };
225
- }
226
-
227
- if (key === "e") {
228
- desiredColumn = null;
229
- let pos = char;
230
- for (let i = 0; i < count; i++) pos = wordEnd(lineText, pos);
231
- return { line, char: clampChar(pos, lineText) };
232
- }
233
-
234
- if (key === "0") {
235
- desiredColumn = null;
236
- return { line, char: 0 };
237
- }
238
-
239
- if (key === "$") {
240
- desiredColumn = null;
241
- return { line, char: clampChar(lineText.length - 1, lineText) };
242
- }
243
-
244
- if (key === "^") {
245
- desiredColumn = null;
246
- return { line, char: firstNonBlank(lineText) };
247
- }
248
-
249
- if (key === "}") {
250
- desiredColumn = null;
251
- return { line: paragraphForward(rep, line, count), char: 0 };
252
- }
253
-
254
- if (key === "{") {
255
- desiredColumn = null;
256
- return { line: paragraphBackward(rep, line, count), char: 0 };
257
- }
258
-
259
- if (key === "G") {
260
- desiredColumn = null;
261
- if (pendingCount !== null) {
262
- return { line: clampLine(pendingCount - 1, rep), char: 0 };
263
- }
264
- return { line: rep.lines.length() - 1, char: 0 };
265
- }
266
-
267
- if (key === ";") {
268
- if (lastCharSearch) {
269
- const pos = charSearchPos(
270
- lastCharSearch.direction,
271
- lineText,
272
- char,
273
- lastCharSearch.target,
274
- count,
275
- );
276
- if (pos !== -1) {
277
- desiredColumn = null;
278
- return { line, char: pos };
279
- }
280
- }
281
- return { line, char };
282
- }
283
-
284
- if (key === ",") {
285
- if (lastCharSearch) {
286
- const opposite = { f: "F", F: "f", t: "T", T: "t" };
287
- const reverseDir = opposite[lastCharSearch.direction];
288
- const pos = charSearchPos(
289
- reverseDir,
290
- lineText,
291
- char,
292
- lastCharSearch.target,
293
- count,
294
- );
295
- if (pos !== -1) {
296
- desiredColumn = null;
297
- return { line, char: pos };
298
- }
299
- }
300
- return { line, char };
301
- }
302
-
303
- if (key === "f" || key === "F" || key === "t" || key === "T") {
304
- pendingKey = key;
305
- return "pending";
306
- }
307
-
308
- if (key === "'" || key === "`") {
309
- pendingKey = key;
310
- return "pending";
311
- }
312
-
313
- if (key === "g") {
314
- pendingKey = "g";
315
- return "pending";
316
- }
317
-
318
- return null;
93
+ const clearEmptyLineCursor = () => {
94
+ if (!editorDoc) return;
95
+ const old = editorDoc.querySelector(".vim-empty-line-cursor");
96
+ if (old) old.classList.remove("vim-empty-line-cursor");
319
97
  };
320
98
 
321
- // --- Apply motion (mode-aware cursor placement) ---
99
+ const scrollLineIntoView = (line) => {
100
+ if (!editorDoc) return;
101
+ const lineDiv = editorDoc.body.querySelectorAll("div")[line];
102
+ if (lineDiv) lineDiv.scrollIntoView({ block: "nearest" });
103
+ };
322
104
 
323
- const applyMotion = (editorInfo, rep, newLine, newChar) => {
324
- if (visualMode !== null) {
325
- visualCursor = [newLine, newChar];
326
- updateVisualSelection(editorInfo, rep);
105
+ const moveBlockCursor = (editorInfo, line, char) => {
106
+ clearEmptyLineCursor();
107
+ const lineText = currentRep ? getLineText(currentRep, line) : "";
108
+ if (lineText.length === 0 && editorDoc) {
109
+ const lineDiv = editorDoc.body.querySelectorAll("div")[line];
110
+ if (lineDiv) lineDiv.classList.add("vim-empty-line-cursor");
111
+ selectRange(editorInfo, [line, 0], [line, 0]);
327
112
  } else {
328
- moveBlockCursor(editorInfo, newLine, newChar);
113
+ selectRange(editorInfo, [line, char], [line, char + 1]);
329
114
  }
115
+ scrollLineIntoView(line);
116
+ };
330
117
 
331
- if (editorDoc) {
332
- const lineDiv = editorDoc.body.querySelectorAll("div")[newLine];
333
- if (lineDiv) lineDiv.scrollIntoView({ block: "nearest" });
334
- }
118
+ const moveVisualCursor = (editorInfo, rep, line, char) => {
119
+ visualCursor = [line, char];
120
+ updateVisualSelection(editorInfo, rep);
121
+ scrollLineIntoView(line);
335
122
  };
336
123
 
337
- // --- Line deletion helper ---
124
+ // --- Line helpers ---
338
125
 
339
126
  const deleteLines = (editorInfo, rep, topLine, bottomLine) => {
340
127
  const totalLines = rep.lines.length();
@@ -361,53 +148,22 @@ const deleteLines = (editorInfo, rep, topLine, bottomLine) => {
361
148
  return 0;
362
149
  };
363
150
 
364
- // --- Operator application ---
365
-
366
- const applyCharOperator = (operator, start, end, editorInfo, rep) => {
367
- if (start[0] === end[0]) {
368
- const lineText = getLineText(rep, start[0]);
369
- setRegister(lineText.slice(start[1], end[1]));
370
- } else {
371
- setRegister(getTextInRange(rep, start, end));
372
- }
373
- if (operator === "y") {
374
- moveBlockCursor(editorInfo, start[0], start[1]);
375
- return;
376
- }
377
- replaceRange(editorInfo, start, end, "");
378
- if (operator === "c") {
379
- moveCursor(editorInfo, start[0], start[1]);
380
- setInsertMode(true);
381
- } else {
382
- const newLineText = getLineText(rep, start[0]);
383
- moveBlockCursor(editorInfo, start[0], clampChar(start[1], newLineText));
384
- }
385
- };
386
-
387
- const applyLineOperator = (
388
- operator,
389
- topLine,
390
- bottomLine,
391
- editorInfo,
392
- rep,
393
- char,
394
- ) => {
151
+ const applyLineOperator = (op, topLine, bottomLine, ctx) => {
152
+ const { editorInfo, rep, char } = ctx;
395
153
  const lines = [];
396
- for (let i = topLine; i <= bottomLine; i++) {
397
- lines.push(getLineText(rep, i));
398
- }
154
+ for (let i = topLine; i <= bottomLine; i++) lines.push(getLineText(rep, i));
399
155
  setRegister(lines);
400
- if (operator === "y") {
156
+ if (op === "y") {
401
157
  moveBlockCursor(editorInfo, topLine, 0);
402
158
  return;
403
159
  }
404
- if (operator === "c") {
160
+ if (op === "c") {
405
161
  for (let i = topLine; i <= bottomLine; i++) {
406
162
  const text = getLineText(rep, i);
407
163
  replaceRange(editorInfo, [topLine, 0], [topLine, text.length], "");
408
164
  }
409
165
  moveCursor(editorInfo, topLine, 0);
410
- setInsertMode(true);
166
+ mode = "insert";
411
167
  return;
412
168
  }
413
169
  const cursorLine = deleteLines(editorInfo, rep, topLine, bottomLine);
@@ -415,390 +171,920 @@ const applyLineOperator = (
415
171
  moveBlockCursor(editorInfo, cursorLine, clampChar(char, newLineText));
416
172
  };
417
173
 
418
- // --- Unified key handler ---
419
-
420
- const handleKey = (rep, editorInfo, key) => {
421
- const inVisual = visualMode !== null;
422
- const line = inVisual ? visualCursor[0] : rep.selStart[0];
423
- const char = inVisual ? visualCursor[1] : rep.selStart[1];
424
- const lineText = getLineText(rep, line);
174
+ // --- Operator helper ---
425
175
 
426
- if (key >= "1" && key <= "9") {
427
- countBuffer += key;
428
- return true;
176
+ const applyOperator = (op, start, end, ctx) => {
177
+ const { editorInfo, rep } = ctx;
178
+ const before =
179
+ start[0] < end[0] || (start[0] === end[0] && start[1] <= end[1]);
180
+ const [s, e] = before ? [start, end] : [end, start];
181
+ setRegister(getTextInRange(rep, s, e));
182
+ if (op === "y") {
183
+ moveBlockCursor(editorInfo, s[0], s[1]);
184
+ return;
429
185
  }
430
- if (key === "0" && countBuffer !== "") {
431
- countBuffer += key;
432
- return true;
186
+ replaceRange(editorInfo, s, e, "");
187
+ if (op === "c") {
188
+ moveCursor(editorInfo, s[0], s[1]);
189
+ mode = "insert";
190
+ } else {
191
+ moveBlockCursor(editorInfo, s[0], s[1]);
433
192
  }
193
+ };
434
194
 
435
- consumeCount();
436
- const count = getCount();
195
+ // --- Command tables ---
196
+ const commands = {
197
+ normal: {},
198
+ "visual-char": {},
199
+ "visual-line": {},
200
+ };
437
201
 
438
- // --- Normal-only pending states: r + char, m + letter ---
202
+ // --- Registration helpers ---
203
+ const OPERATORS = ["d", "c", "y"];
439
204
 
440
- if (pendingKey === "r") {
441
- pendingKey = null;
442
- if (lineText.length > 0) {
443
- replaceRange(editorInfo, [line, char], [line, char + 1], key);
444
- moveBlockCursor(editorInfo, line, char);
445
- }
446
- return true;
205
+ const resolveTextObject = (key, type, line, lineText, char, rep) => {
206
+ if (key === "p") {
207
+ return paragraphTextRange(rep, line, type);
447
208
  }
448
-
449
- if (pendingKey === "m") {
450
- pendingKey = null;
451
- if (key >= "a" && key <= "z") {
452
- marks[key] = [line, char];
453
- }
454
- return true;
209
+ if (key === "s") {
210
+ const r = sentenceTextRange(lineText, char, type);
211
+ if (!r) return null;
212
+ return {
213
+ startLine: line,
214
+ startChar: r.start,
215
+ endLine: line,
216
+ endChar: r.end,
217
+ };
455
218
  }
219
+ const r =
220
+ key === "w"
221
+ ? textWordRange(lineText, char, type)
222
+ : ["(", ")", "{", "}", "[", "]"].includes(key)
223
+ ? textBracketRange(lineText, char, key, type)
224
+ : ['"', "'", "`"].includes(key)
225
+ ? textQuoteRange(lineText, char, key, type)
226
+ : null;
227
+ if (!r) return null;
228
+ return { startLine: line, startChar: r.start, endLine: line, endChar: r.end };
229
+ };
456
230
 
457
- // --- Operator-pending: resolve target ---
231
+ const recordCommand = (key, count, param = null) => {
232
+ lastCommand = { key, count, param };
233
+ };
458
234
 
459
- if (pendingOperator !== null) {
460
- const op = pendingOperator;
235
+ const registerMotion = (key, getEndPos, inclusive = false) => {
236
+ commands.normal[key] = (ctx) => {
237
+ desiredColumn = null;
238
+ const pos = getEndPos(ctx);
239
+ if (pos) moveBlockCursor(ctx.editorInfo, pos.line, pos.char);
240
+ };
241
+ commands["visual-char"][key] = (ctx) => {
242
+ desiredColumn = null;
243
+ const pos = getEndPos(ctx);
244
+ if (pos) moveVisualCursor(ctx.editorInfo, ctx.rep, pos.line, pos.char);
245
+ };
246
+ commands["visual-line"][key] = (ctx) => {
247
+ desiredColumn = null;
248
+ const pos = getEndPos(ctx);
249
+ if (pos) moveVisualCursor(ctx.editorInfo, ctx.rep, pos.line, pos.char);
250
+ };
251
+ for (const op of OPERATORS) {
252
+ commands.normal[op + key] = (ctx) => {
253
+ desiredColumn = null;
254
+ const pos = getEndPos(ctx);
255
+ if (pos) {
256
+ const endChar = inclusive ? pos.char + 1 : pos.char;
257
+ applyOperator(op, [ctx.line, ctx.char], [pos.line, endChar], ctx);
258
+ recordCommand(op + key, ctx.count);
259
+ }
260
+ };
261
+ }
262
+ };
461
263
 
462
- if (key === op) {
463
- pendingOperator = null;
464
- const lineCount = rep.lines.length();
465
- const opCount = Math.min(count, lineCount - line);
466
- const lastLine = line + opCount - 1;
467
- applyLineOperator(op, line, lastLine, editorInfo, rep, char);
468
- return true;
469
- }
264
+ const parameterized = {};
470
265
 
471
- if (pendingKey === "i" || pendingKey === "a") {
472
- const type = pendingKey;
473
- pendingKey = null;
474
- pendingOperator = null;
475
- const range = textObjectRange(key, lineText, char, type);
476
- if (range) {
477
- applyCharOperator(
478
- op,
479
- [line, range.start],
480
- [line, range.end],
481
- editorInfo,
482
- rep,
483
- );
266
+ const registerParamMotion = (key, getEndChar) => {
267
+ commands.normal[key] = () => {
268
+ pendingKey = key;
269
+ };
270
+ commands["visual-char"][key] = () => {
271
+ pendingKey = key;
272
+ };
273
+ commands["visual-line"][key] = () => {
274
+ pendingKey = key;
275
+ };
276
+ parameterized[key] = (argKey, ctx) => {
277
+ lastCharSearch = { direction: key, target: argKey };
278
+ const pos = getEndChar(argKey, ctx);
279
+ if (pos !== null) {
280
+ if (mode.startsWith("visual")) {
281
+ moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
282
+ } else {
283
+ moveBlockCursor(ctx.editorInfo, ctx.line, pos);
484
284
  }
485
- return true;
285
+ recordCommand(key, ctx.count, argKey);
486
286
  }
487
-
488
- if (
489
- pendingKey === "f" ||
490
- pendingKey === "F" ||
491
- pendingKey === "t" ||
492
- pendingKey === "T"
493
- ) {
494
- const direction = pendingKey;
495
- pendingKey = null;
496
- pendingOperator = null;
497
- lastCharSearch = { direction, target: key };
498
- const pos = charSearchPos(direction, lineText, char, key, count);
499
- if (pos !== -1) {
500
- const range = charMotionRange(direction, char, pos);
501
- if (range) {
502
- applyCharOperator(
287
+ };
288
+ for (const op of OPERATORS) {
289
+ const combo = op + key;
290
+ commands.normal[combo] = () => {
291
+ pendingKey = combo;
292
+ };
293
+ parameterized[combo] = (argKey, ctx) => {
294
+ lastCharSearch = { direction: key, target: argKey };
295
+ const pos = getEndChar(argKey, ctx);
296
+ if (pos !== null) {
297
+ const range = charMotionRange(key, ctx.char, pos);
298
+ if (range)
299
+ applyOperator(
503
300
  op,
504
- [line, range.start],
505
- [line, range.end],
506
- editorInfo,
507
- rep,
301
+ [ctx.line, range.start],
302
+ [ctx.line, range.end],
303
+ ctx,
508
304
  );
509
- }
305
+ recordCommand(combo, ctx.count, argKey);
510
306
  }
511
- return true;
512
- }
307
+ };
308
+ }
309
+ };
513
310
 
514
- if (key === "i" || key === "a") {
515
- pendingKey = key;
516
- return true;
311
+ const registerTextObject = (obj, getRange) => {
312
+ for (const op of OPERATORS) {
313
+ for (const type of ["i", "a"]) {
314
+ commands.normal[`${op}${type}${obj}`] = (ctx) => {
315
+ const range = getRange(ctx, type);
316
+ if (range) applyOperator(op, range.start, range.end, ctx);
317
+ };
517
318
  }
319
+ }
320
+ };
518
321
 
519
- if (key === "f" || key === "F" || key === "t" || key === "T") {
520
- pendingKey = key;
521
- return true;
322
+ const getVisibleLineRange = (rep) => {
323
+ const totalLines = rep.lines.length();
324
+ if (!editorDoc) return { top: 0, bottom: totalLines - 1 };
325
+ const lineDivs = editorDoc.body.querySelectorAll("div");
326
+ const lineCount = Math.min(lineDivs.length, totalLines);
327
+ const frameEl = editorDoc.defaultView.frameElement;
328
+ const iframeTop = frameEl ? frameEl.getBoundingClientRect().top : 0;
329
+ const outerViewportHeight = window.parent ? window.parent.innerHeight : 600;
330
+ let top = 0;
331
+ let bottom = lineCount - 1;
332
+ for (let i = 0; i < lineCount; i++) {
333
+ const rect = lineDivs[i].getBoundingClientRect();
334
+ if (iframeTop + rect.bottom > 0) {
335
+ top = i;
336
+ break;
522
337
  }
523
-
524
- pendingOperator = null;
525
- const range = motionRange(key, char, lineText, count);
526
- if (range && range.end > range.start) {
527
- applyCharOperator(
528
- op,
529
- [line, range.start],
530
- [line, range.end],
531
- editorInfo,
532
- rep,
533
- );
338
+ }
339
+ for (let i = lineCount - 1; i >= 0; i--) {
340
+ const rect = lineDivs[i].getBoundingClientRect();
341
+ if (iframeTop + rect.top < outerViewportHeight) {
342
+ bottom = i;
343
+ break;
534
344
  }
535
- return true;
536
345
  }
537
-
538
- // --- Text object in visual mode (i/a + object key) ---
539
-
540
- if (inVisual && (pendingKey === "i" || pendingKey === "a")) {
541
- const type = pendingKey;
542
- pendingKey = null;
543
- const range = textObjectRange(key, lineText, char, type);
544
- if (range) {
545
- visualAnchor = [line, range.start];
546
- visualCursor = [line, range.end];
547
- setVisualMode("char");
548
- updateVisualSelection(editorInfo, rep);
346
+ const visibleTop = iframeTop + lineDivs[top].getBoundingClientRect().top;
347
+ const visibleBottom =
348
+ iframeTop + lineDivs[bottom].getBoundingClientRect().bottom;
349
+ const pixelMidpoint = (visibleTop + visibleBottom) / 2;
350
+ let mid = top;
351
+ for (let i = top; i <= bottom; i++) {
352
+ const rect = lineDivs[i].getBoundingClientRect();
353
+ if (iframeTop + (rect.top + rect.bottom) / 2 >= pixelMidpoint) {
354
+ mid = i;
355
+ break;
549
356
  }
550
- return true;
551
357
  }
358
+ return { top, mid, bottom };
359
+ };
552
360
 
553
- // --- Motions (shared between normal and visual) ---
361
+ // --- Motions ---
362
+ registerMotion("h", (ctx) => ({
363
+ line: ctx.line,
364
+ char: Math.max(0, ctx.char - ctx.count),
365
+ }));
366
+
367
+ registerMotion("l", (ctx) => ({
368
+ line: ctx.line,
369
+ char: clampChar(ctx.char + ctx.count, ctx.lineText),
370
+ }));
371
+
372
+ registerMotion("j", (ctx) => {
373
+ if (desiredColumn === null) desiredColumn = ctx.char;
374
+ const newLine = clampLine(ctx.line + ctx.count, ctx.rep);
375
+ const newLineText = getLineText(ctx.rep, newLine);
376
+ return { line: newLine, char: clampChar(desiredColumn, newLineText) };
377
+ });
378
+
379
+ registerMotion("k", (ctx) => {
380
+ if (desiredColumn === null) desiredColumn = ctx.char;
381
+ const newLine = clampLine(ctx.line - ctx.count, ctx.rep);
382
+ const newLineText = getLineText(ctx.rep, newLine);
383
+ return { line: newLine, char: clampChar(desiredColumn, newLineText) };
384
+ });
385
+
386
+ registerMotion("w", (ctx) => {
387
+ let pos = ctx.char;
388
+ for (let i = 0; i < ctx.count; i++) pos = wordForward(ctx.lineText, pos);
389
+ return { line: ctx.line, char: clampChar(pos, ctx.lineText) };
390
+ });
391
+
392
+ registerMotion("b", (ctx) => {
393
+ let pos = ctx.char;
394
+ for (let i = 0; i < ctx.count; i++) pos = wordBackward(ctx.lineText, pos);
395
+ return { line: ctx.line, char: pos };
396
+ });
397
+
398
+ registerMotion(
399
+ "e",
400
+ (ctx) => {
401
+ let pos = ctx.char;
402
+ for (let i = 0; i < ctx.count; i++) pos = wordEnd(ctx.lineText, pos);
403
+ return { line: ctx.line, char: clampChar(pos, ctx.lineText) };
404
+ },
405
+ true,
406
+ );
407
+
408
+ registerMotion("0", (_ctx) => ({ line: _ctx.line, char: 0 }));
409
+
410
+ registerMotion(
411
+ "$",
412
+ (ctx) => ({
413
+ line: ctx.line,
414
+ char: clampChar(ctx.lineText.length - 1, ctx.lineText),
415
+ }),
416
+ true,
417
+ );
418
+
419
+ registerMotion("^", (ctx) => ({
420
+ line: ctx.line,
421
+ char: firstNonBlank(ctx.lineText),
422
+ }));
423
+
424
+ registerMotion("gg", (ctx) => ({
425
+ line: ctx.hasCount ? clampLine(ctx.count - 1, ctx.rep) : 0,
426
+ char: 0,
427
+ }));
428
+
429
+ registerMotion("G", (ctx) => ({
430
+ line: ctx.hasCount
431
+ ? clampLine(ctx.count - 1, ctx.rep)
432
+ : ctx.rep.lines.length() - 1,
433
+ char: 0,
434
+ }));
435
+
436
+ registerMotion("{", (ctx) => ({
437
+ line: paragraphBackward(ctx.rep, ctx.line, ctx.count),
438
+ char: 0,
439
+ }));
440
+
441
+ registerMotion("}", (ctx) => ({
442
+ line: paragraphForward(ctx.rep, ctx.line, ctx.count),
443
+ char: 0,
444
+ }));
445
+
446
+ registerMotion(
447
+ "%",
448
+ (ctx) => {
449
+ const pos = matchingBracketPos(ctx.rep, ctx.line, ctx.char);
450
+ return pos || { line: ctx.line, char: ctx.char };
451
+ },
452
+ true,
453
+ );
454
+
455
+ registerMotion("H", (ctx) => {
456
+ const { top } = getVisibleLineRange(ctx.rep);
457
+ const targetLine = clampLine(top + ctx.count - 1, ctx.rep);
458
+ return {
459
+ line: targetLine,
460
+ char: firstNonBlank(getLineText(ctx.rep, targetLine)),
461
+ };
462
+ });
463
+
464
+ registerMotion("M", (ctx) => {
465
+ const { mid } = getVisibleLineRange(ctx.rep);
466
+ return {
467
+ line: mid,
468
+ char: firstNonBlank(getLineText(ctx.rep, mid)),
469
+ };
470
+ });
471
+
472
+ registerMotion("L", (ctx) => {
473
+ const { bottom } = getVisibleLineRange(ctx.rep);
474
+ const targetLine = clampLine(bottom - ctx.count + 1, ctx.rep);
475
+ return {
476
+ line: targetLine,
477
+ char: firstNonBlank(getLineText(ctx.rep, targetLine)),
478
+ };
479
+ });
480
+
481
+ registerParamMotion("f", (key, ctx) => {
482
+ const pos = charSearchPos("f", ctx.lineText, ctx.char, key, ctx.count);
483
+ return pos !== -1 ? pos : null;
484
+ });
485
+
486
+ registerParamMotion("F", (key, ctx) => {
487
+ const pos = charSearchPos("F", ctx.lineText, ctx.char, key, ctx.count);
488
+ return pos !== -1 ? pos : null;
489
+ });
490
+
491
+ registerParamMotion("t", (key, ctx) => {
492
+ const pos = charSearchPos("t", ctx.lineText, ctx.char, key, ctx.count);
493
+ return pos !== -1 ? pos : null;
494
+ });
495
+
496
+ registerParamMotion("T", (key, ctx) => {
497
+ const pos = charSearchPos("T", ctx.lineText, ctx.char, key, ctx.count);
498
+ return pos !== -1 ? pos : null;
499
+ });
500
+
501
+ commands.normal[";"] = (ctx) => {
502
+ if (!lastCharSearch) return;
503
+ const pos = charSearchPos(
504
+ lastCharSearch.direction,
505
+ ctx.lineText,
506
+ ctx.char,
507
+ lastCharSearch.target,
508
+ ctx.count,
509
+ );
510
+ if (pos !== -1) moveBlockCursor(ctx.editorInfo, ctx.line, pos);
511
+ };
554
512
 
555
- const motion = resolveMotion(key, line, char, lineText, rep, count);
556
- if (motion === "pending") return true;
557
- if (motion) {
558
- applyMotion(editorInfo, rep, motion.line, motion.char);
559
- return true;
513
+ commands.normal[","] = (ctx) => {
514
+ if (!lastCharSearch) return;
515
+ const opposite = { f: "F", F: "f", t: "T", T: "t" };
516
+ const dir = opposite[lastCharSearch.direction];
517
+ const pos = charSearchPos(
518
+ dir,
519
+ ctx.lineText,
520
+ ctx.char,
521
+ lastCharSearch.target,
522
+ ctx.count,
523
+ );
524
+ if (pos !== -1) moveBlockCursor(ctx.editorInfo, ctx.line, pos);
525
+ };
526
+
527
+ commands["visual-char"][";"] = (ctx) => {
528
+ if (!lastCharSearch) return;
529
+ const pos = charSearchPos(
530
+ lastCharSearch.direction,
531
+ ctx.lineText,
532
+ ctx.char,
533
+ lastCharSearch.target,
534
+ ctx.count,
535
+ );
536
+ if (pos !== -1) moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
537
+ };
538
+
539
+ commands["visual-char"][","] = (ctx) => {
540
+ if (!lastCharSearch) return;
541
+ const opposite = { f: "F", F: "f", t: "T", T: "t" };
542
+ const dir = opposite[lastCharSearch.direction];
543
+ const pos = charSearchPos(
544
+ dir,
545
+ ctx.lineText,
546
+ ctx.char,
547
+ lastCharSearch.target,
548
+ ctx.count,
549
+ );
550
+ if (pos !== -1) moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
551
+ };
552
+
553
+ commands["visual-line"][";"] = (ctx) => {
554
+ if (!lastCharSearch) return;
555
+ const pos = charSearchPos(
556
+ lastCharSearch.direction,
557
+ ctx.lineText,
558
+ ctx.char,
559
+ lastCharSearch.target,
560
+ ctx.count,
561
+ );
562
+ if (pos !== -1) moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
563
+ };
564
+
565
+ commands["visual-line"][","] = (ctx) => {
566
+ if (!lastCharSearch) return;
567
+ const opposite = { f: "F", F: "f", t: "T", T: "t" };
568
+ const dir = opposite[lastCharSearch.direction];
569
+ const pos = charSearchPos(
570
+ dir,
571
+ ctx.lineText,
572
+ ctx.char,
573
+ lastCharSearch.target,
574
+ ctx.count,
575
+ );
576
+ if (pos !== -1) moveVisualCursor(ctx.editorInfo, ctx.rep, ctx.line, pos);
577
+ };
578
+
579
+ // --- Marks ---
580
+
581
+ commands.normal["m"] = () => {
582
+ pendingKey = "m";
583
+ };
584
+ parameterized["m"] = (key, ctx) => {
585
+ if (key >= "a" && key <= "z") marks[key] = [ctx.line, ctx.char];
586
+ };
587
+
588
+ commands.normal["'"] = () => {
589
+ pendingKey = "'";
590
+ };
591
+ commands["visual-char"]["'"] = () => {
592
+ pendingKey = "'";
593
+ };
594
+ commands["visual-line"]["'"] = () => {
595
+ pendingKey = "'";
596
+ };
597
+ parameterized["'"] = (key, ctx) => {
598
+ if (!marks[key]) return;
599
+ const [markLine] = marks[key];
600
+ const targetText = getLineText(ctx.rep, markLine);
601
+ const targetChar = firstNonBlank(targetText);
602
+ if (mode.startsWith("visual")) {
603
+ moveVisualCursor(ctx.editorInfo, ctx.rep, markLine, targetChar);
604
+ } else {
605
+ moveBlockCursor(ctx.editorInfo, markLine, targetChar);
606
+ }
607
+ };
608
+
609
+ commands.normal["`"] = () => {
610
+ pendingKey = "`";
611
+ };
612
+ commands["visual-char"]["`"] = () => {
613
+ pendingKey = "`";
614
+ };
615
+ commands["visual-line"]["`"] = () => {
616
+ pendingKey = "`";
617
+ };
618
+ parameterized["`"] = (key, ctx) => {
619
+ if (!marks[key]) return;
620
+ const [markLine, markChar] = marks[key];
621
+ if (mode.startsWith("visual")) {
622
+ moveVisualCursor(ctx.editorInfo, ctx.rep, markLine, markChar);
623
+ } else {
624
+ moveBlockCursor(ctx.editorInfo, markLine, markChar);
560
625
  }
626
+ };
561
627
 
562
- // --- Operators (d/c/y) ---
628
+ // --- Text objects ---
563
629
 
564
- if (key === "d" || key === "c" || key === "y") {
565
- if (inVisual) {
566
- if (visualMode === "char") {
567
- const [start, end] = getVisualSelection(
568
- visualMode,
569
- visualAnchor,
570
- visualCursor,
571
- rep,
572
- );
573
- setVisualMode(null);
574
- applyCharOperator(key, start, end, editorInfo, rep);
575
- } else {
576
- const topLine = Math.min(visualAnchor[0], visualCursor[0]);
577
- const bottomLine = Math.max(visualAnchor[0], visualCursor[0]);
578
- setVisualMode(null);
579
- applyLineOperator(key, topLine, bottomLine, editorInfo, rep, 0);
580
- }
581
- return true;
582
- }
583
- pendingOperator = key;
584
- return true;
630
+ registerTextObject("w", (ctx, type) => {
631
+ const r = textWordRange(ctx.lineText, ctx.char, type);
632
+ if (!r) return null;
633
+ return { start: [ctx.line, r.start], end: [ctx.line, r.end] };
634
+ });
635
+
636
+ for (const q of ['"', "'", "`"]) {
637
+ registerTextObject(q, (ctx, type) => {
638
+ const r = textQuoteRange(ctx.lineText, ctx.char, q, type);
639
+ if (!r) return null;
640
+ return { start: [ctx.line, r.start], end: [ctx.line, r.end] };
641
+ });
642
+ }
643
+
644
+ for (const bracket of ["(", ")", "{", "}", "[", "]"]) {
645
+ registerTextObject(bracket, (ctx, type) => {
646
+ const r = textBracketRange(ctx.lineText, ctx.char, bracket, type);
647
+ if (!r) return null;
648
+ return { start: [ctx.line, r.start], end: [ctx.line, r.end] };
649
+ });
650
+ }
651
+
652
+ registerTextObject("p", (ctx, type) => {
653
+ const r = paragraphTextRange(ctx.rep, ctx.line, type);
654
+ if (!r) return null;
655
+ return {
656
+ start: [r.startLine, r.startChar],
657
+ end: [r.endLine, r.endChar],
658
+ };
659
+ });
660
+
661
+ registerTextObject("s", (ctx, type) => {
662
+ const r = sentenceTextRange(ctx.lineText, ctx.char, type);
663
+ if (!r) return null;
664
+ return { start: [ctx.line, r.start], end: [ctx.line, r.end] };
665
+ });
666
+
667
+ // --- Line operators ---
668
+
669
+ for (const op of OPERATORS) {
670
+ commands.normal[op + op] = (ctx) => {
671
+ const bottomLine = clampLine(ctx.line + ctx.count - 1, ctx.rep);
672
+ applyLineOperator(op, ctx.line, bottomLine, ctx);
673
+ recordCommand(op + op, ctx.count);
674
+ };
675
+ }
676
+
677
+ // --- Visual modes ---
678
+
679
+ commands.normal["v"] = ({ editorInfo, rep, line, char }) => {
680
+ visualAnchor = [line, char];
681
+ visualCursor = [line, char];
682
+ mode = "visual-char";
683
+ updateVisualSelection(editorInfo, rep);
684
+ };
685
+
686
+ commands.normal["V"] = ({ editorInfo, rep, line }) => {
687
+ visualAnchor = [line, 0];
688
+ visualCursor = [line, 0];
689
+ mode = "visual-line";
690
+ updateVisualSelection(editorInfo, rep);
691
+ };
692
+
693
+ for (const op of OPERATORS) {
694
+ commands["visual-line"][op] = (ctx) => {
695
+ const topLine = Math.min(visualAnchor[0], visualCursor[0]);
696
+ const bottomLine = Math.max(visualAnchor[0], visualCursor[0]);
697
+ mode = "normal";
698
+ applyLineOperator(op, topLine, bottomLine, ctx);
699
+ };
700
+ }
701
+
702
+ for (const op of OPERATORS) {
703
+ commands["visual-char"][op] = (ctx) => {
704
+ const [start, end] = getVisualSelection(
705
+ "char",
706
+ visualAnchor,
707
+ visualCursor,
708
+ ctx.rep,
709
+ );
710
+ mode = "normal";
711
+ applyOperator(op, start, end, ctx);
712
+ };
713
+ }
714
+
715
+ commands["visual-char"]["~"] = (ctx) => {
716
+ const [start, end] = getVisualSelection(
717
+ "char",
718
+ visualAnchor,
719
+ visualCursor,
720
+ ctx.rep,
721
+ );
722
+ const text = getTextInRange(ctx.rep, start, end);
723
+ let toggled = "";
724
+ for (let i = 0; i < text.length; i++) {
725
+ const ch = text[i];
726
+ toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
585
727
  }
728
+ replaceRange(ctx.editorInfo, start, end, toggled);
729
+ mode = "normal";
730
+ moveBlockCursor(ctx.editorInfo, start[0], start[1]);
731
+ };
586
732
 
587
- // --- Visual-mode specific ---
733
+ commands["visual-line"]["~"] = (ctx) => {
734
+ const [start, end] = getVisualSelection(
735
+ "line",
736
+ visualAnchor,
737
+ visualCursor,
738
+ ctx.rep,
739
+ );
740
+ const text = getTextInRange(ctx.rep, start, end);
741
+ let toggled = "";
742
+ for (let i = 0; i < text.length; i++) {
743
+ const ch = text[i];
744
+ toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
745
+ }
746
+ replaceRange(ctx.editorInfo, start, end, toggled);
747
+ mode = "normal";
748
+ moveBlockCursor(ctx.editorInfo, start[0], start[1]);
749
+ };
588
750
 
589
- if (inVisual) {
590
- if (key === "i" || key === "a") {
591
- pendingKey = key;
592
- return true;
593
- }
751
+ // --- Miscellaneous ---
594
752
 
595
- if (key === "~") {
596
- const [start, end] = getVisualSelection(
597
- visualMode,
598
- visualAnchor,
599
- visualCursor,
600
- rep,
601
- );
602
- const text = getTextInRange(rep, start, end);
603
- let toggled = "";
604
- for (let i = 0; i < text.length; i++) {
605
- const ch = text[i];
606
- toggled +=
607
- ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
608
- }
609
- replaceRange(editorInfo, start, end, toggled);
610
- setVisualMode(null);
611
- moveBlockCursor(editorInfo, start[0], start[1]);
612
- return true;
613
- }
753
+ commands.normal["u"] = ({ editorInfo }) => {
754
+ editorInfo.ace_doUndoRedo("undo");
755
+ };
614
756
 
615
- pendingKey = null;
616
- return false;
757
+ commands.normal["."] = (ctx) => {
758
+ if (!lastCommand) return;
759
+ const { key, count, param } = lastCommand;
760
+ if (param !== null && parameterized[key]) {
761
+ parameterized[key](param, ctx);
762
+ } else if (commands[mode] && commands[mode][key]) {
763
+ const newCtx = { ...ctx, count };
764
+ commands[mode][key](newCtx);
617
765
  }
766
+ };
618
767
 
619
- // --- Normal-mode only commands ---
768
+ // --- Mode transitions ---
620
769
 
621
- if (key === "Y") {
622
- setRegister([lineText]);
623
- return true;
770
+ commands.normal["i"] = ({ editorInfo, line, char }) => {
771
+ clearEmptyLineCursor();
772
+ moveCursor(editorInfo, line, char);
773
+ mode = "insert";
774
+ };
775
+
776
+ commands.normal["a"] = ({ editorInfo, line, char, lineText }) => {
777
+ clearEmptyLineCursor();
778
+ moveCursor(editorInfo, line, Math.min(char + 1, lineText.length));
779
+ mode = "insert";
780
+ };
781
+
782
+ commands.normal["A"] = ({ editorInfo, line, lineText }) => {
783
+ clearEmptyLineCursor();
784
+ moveCursor(editorInfo, line, lineText.length);
785
+ mode = "insert";
786
+ };
787
+
788
+ commands.normal["I"] = ({ editorInfo, line, lineText }) => {
789
+ clearEmptyLineCursor();
790
+ moveCursor(editorInfo, line, firstNonBlank(lineText));
791
+ mode = "insert";
792
+ };
793
+
794
+ commands["visual-char"]["i"] = () => {
795
+ pendingKey = "i";
796
+ };
797
+
798
+ commands["visual-char"]["a"] = () => {
799
+ pendingKey = "a";
800
+ };
801
+
802
+ commands["visual-line"]["i"] = () => {
803
+ pendingKey = "i";
804
+ };
805
+
806
+ commands["visual-line"]["a"] = () => {
807
+ pendingKey = "a";
808
+ };
809
+
810
+ commands.normal["o"] = ({ editorInfo, line, lineText }) => {
811
+ clearEmptyLineCursor();
812
+ replaceRange(
813
+ editorInfo,
814
+ [line, lineText.length],
815
+ [line, lineText.length],
816
+ "\n",
817
+ );
818
+ moveCursor(editorInfo, line + 1, 0);
819
+ mode = "insert";
820
+ };
821
+
822
+ commands.normal["O"] = ({ editorInfo, line }) => {
823
+ clearEmptyLineCursor();
824
+ replaceRange(editorInfo, [line, 0], [line, 0], "\n");
825
+ moveCursor(editorInfo, line, 0);
826
+ mode = "insert";
827
+ };
828
+
829
+ // --- More normal mode commands ---
830
+
831
+ commands.normal["r"] = () => {
832
+ pendingKey = "r";
833
+ };
834
+ parameterized["r"] = (key, { editorInfo, line, char, lineText, count }) => {
835
+ if (lineText.length > 0) {
836
+ replaceRange(editorInfo, [line, char], [line, char + 1], key);
837
+ moveBlockCursor(editorInfo, line, char);
838
+ recordCommand("r", count, key);
624
839
  }
840
+ };
625
841
 
626
- if (key === "r") {
627
- if (lineText.length > 0) pendingKey = "r";
628
- return true;
842
+ commands.normal["Y"] = ({ rep, line }) => {
843
+ setRegister([getLineText(rep, line)]);
844
+ };
845
+
846
+ commands.normal["x"] = ({ editorInfo, rep, line, char, lineText, count }) => {
847
+ if (lineText.length > 0) {
848
+ const deleteCount = Math.min(count, lineText.length - char);
849
+ setRegister(lineText.slice(char, char + deleteCount));
850
+ replaceRange(editorInfo, [line, char], [line, char + deleteCount], "");
851
+ const newLineText = getLineText(rep, line);
852
+ moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
853
+ recordCommand("x", count);
629
854
  }
855
+ };
630
856
 
631
- if (key === "m") {
632
- pendingKey = "m";
633
- return true;
857
+ commands.normal["p"] = ({ editorInfo, line, char, lineText, count }) => {
858
+ if (register !== null) {
859
+ if (typeof register === "string") {
860
+ const insertPos = Math.min(char + 1, lineText.length);
861
+ const repeated = register.repeat(count);
862
+ replaceRange(editorInfo, [line, insertPos], [line, insertPos], repeated);
863
+ moveBlockCursor(editorInfo, line, insertPos);
864
+ } else {
865
+ const block = register.join("\n");
866
+ const parts = [];
867
+ for (let i = 0; i < count; i++) parts.push(block);
868
+ const insertText = "\n" + parts.join("\n");
869
+ replaceRange(
870
+ editorInfo,
871
+ [line, lineText.length],
872
+ [line, lineText.length],
873
+ insertText,
874
+ );
875
+ moveBlockCursor(editorInfo, line + 1, 0);
876
+ }
877
+ recordCommand("p", count);
634
878
  }
879
+ };
635
880
 
636
- if (key === "x") {
637
- if (lineText.length > 0) {
638
- const deleteCount = Math.min(count, lineText.length - char);
639
- replaceRange(editorInfo, [line, char], [line, char + deleteCount], "");
640
- const newLineText = getLineText(rep, line);
641
- moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
881
+ commands.normal["P"] = ({ editorInfo, line, char, count }) => {
882
+ if (register !== null) {
883
+ if (typeof register === "string") {
884
+ const repeated = register.repeat(count);
885
+ replaceRange(editorInfo, [line, char], [line, char], repeated);
886
+ moveBlockCursor(editorInfo, line, char);
887
+ } else {
888
+ const block = register.join("\n");
889
+ const parts = [];
890
+ for (let i = 0; i < count; i++) parts.push(block);
891
+ const insertText = parts.join("\n") + "\n";
892
+ replaceRange(editorInfo, [line, 0], [line, 0], insertText);
893
+ moveBlockCursor(editorInfo, line, 0);
642
894
  }
643
- return true;
895
+ recordCommand("P", count);
644
896
  }
897
+ };
645
898
 
646
- if (key === "o") {
899
+ commands.normal["J"] = ({ editorInfo, rep, line, lineText, count }) => {
900
+ const lineCount = rep.lines.length();
901
+ const joins = Math.min(count, lineCount - 1 - line);
902
+ let cursorChar = lineText.length;
903
+ for (let i = 0; i < joins; i++) {
904
+ const curLineText = getLineText(rep, line);
905
+ const nextLineText = getLineText(rep, line + 1);
906
+ const trimmedNext = nextLineText.replace(/^\s+/, "");
907
+ const separator = curLineText.length === 0 ? "" : " ";
908
+ if (i === 0) cursorChar = curLineText.length;
647
909
  replaceRange(
648
910
  editorInfo,
649
- [line, lineText.length],
650
- [line, lineText.length],
651
- "\n",
911
+ [line, curLineText.length],
912
+ [line + 1, nextLineText.length],
913
+ separator + trimmedNext,
652
914
  );
653
- moveCursor(editorInfo, line + 1, 0);
654
- setInsertMode(true);
655
- return true;
656
915
  }
916
+ moveBlockCursor(editorInfo, line, cursorChar);
917
+ recordCommand("J", count);
918
+ };
657
919
 
658
- if (key === "O") {
659
- replaceRange(editorInfo, [line, 0], [line, 0], "\n");
660
- moveCursor(editorInfo, line, 0);
661
- setInsertMode(true);
662
- return true;
920
+ commands.normal["~"] = ({ editorInfo, rep, line, char, lineText, count }) => {
921
+ if (lineText.length > 0) {
922
+ const toggleCount = Math.min(count, lineText.length - char);
923
+ const slice = lineText.slice(char, char + toggleCount);
924
+ let toggled = "";
925
+ for (let i = 0; i < slice.length; i++) {
926
+ const ch = slice[i];
927
+ toggled += ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
928
+ }
929
+ replaceRange(editorInfo, [line, char], [line, char + toggleCount], toggled);
930
+ const newChar = Math.min(char + toggleCount, lineText.length - 1);
931
+ moveBlockCursor(editorInfo, line, newChar);
932
+ recordCommand("~", count);
663
933
  }
934
+ };
664
935
 
665
- if (key === "u") {
666
- undo(editorInfo);
667
- return true;
668
- }
936
+ commands.normal["D"] = ({ editorInfo, rep, line, char, lineText }) => {
937
+ setRegister(lineText.slice(char));
938
+ replaceRange(editorInfo, [line, char], [line, lineText.length], "");
939
+ const newLineText = getLineText(rep, line);
940
+ moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
941
+ recordCommand("D", 1);
942
+ };
669
943
 
670
- if (key === "p") {
671
- if (register !== null) {
672
- if (typeof register === "string") {
673
- const insertPos = Math.min(char + 1, lineText.length);
674
- const repeated = register.repeat(count);
675
- replaceRange(
676
- editorInfo,
677
- [line, insertPos],
678
- [line, insertPos],
679
- repeated,
680
- );
681
- moveBlockCursor(editorInfo, line, insertPos);
682
- } else {
683
- const block = register.join("\n");
684
- const parts = [];
685
- for (let i = 0; i < count; i++) parts.push(block);
686
- const insertText = "\n" + parts.join("\n");
687
- replaceRange(
688
- editorInfo,
689
- [line, lineText.length],
690
- [line, lineText.length],
691
- insertText,
692
- );
693
- moveBlockCursor(editorInfo, line + 1, 0);
694
- }
695
- }
944
+ commands.normal["C"] = ({ editorInfo, line, char, lineText }) => {
945
+ clearEmptyLineCursor();
946
+ setRegister(lineText.slice(char));
947
+ replaceRange(editorInfo, [line, char], [line, lineText.length], "");
948
+ moveCursor(editorInfo, line, char);
949
+ mode = "insert";
950
+ recordCommand("C", 1);
951
+ };
952
+
953
+ commands.normal["s"] = ({ editorInfo, rep, line, char, lineText, count }) => {
954
+ clearEmptyLineCursor();
955
+ setRegister(lineText.slice(char, char + 1));
956
+ replaceRange(
957
+ editorInfo,
958
+ [line, char],
959
+ [line, Math.min(char + count, lineText.length)],
960
+ "",
961
+ );
962
+ moveCursor(editorInfo, line, char);
963
+ mode = "insert";
964
+ recordCommand("s", count);
965
+ };
966
+
967
+ commands.normal["S"] = ({ editorInfo, line, lineText }) => {
968
+ clearEmptyLineCursor();
969
+ setRegister(lineText);
970
+ replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
971
+ moveCursor(editorInfo, line, 0);
972
+ mode = "insert";
973
+ recordCommand("S", 1);
974
+ };
975
+
976
+ // --- Search ---
977
+
978
+ commands.normal["/"] = () => {
979
+ searchMode = true;
980
+ searchBuffer = "";
981
+ searchDirection = "/";
982
+ };
983
+
984
+ commands.normal["?"] = () => {
985
+ searchMode = true;
986
+ searchBuffer = "";
987
+ searchDirection = "?";
988
+ };
989
+
990
+ commands.normal["n"] = (ctx) => {
991
+ if (!lastSearch) return;
992
+ const { pattern, direction } = lastSearch;
993
+ const searchFunc = direction === "/" ? searchForward : searchBackward;
994
+ const pos = searchFunc(ctx.rep, ctx.line, ctx.char + 1, pattern, ctx.count);
995
+ if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
996
+ };
997
+
998
+ commands.normal["N"] = (ctx) => {
999
+ if (!lastSearch) return;
1000
+ const { pattern, direction } = lastSearch;
1001
+ const searchFunc = direction === "/" ? searchBackward : searchForward;
1002
+ const pos = searchFunc(ctx.rep, ctx.line, ctx.char, pattern, ctx.count);
1003
+ if (pos) moveBlockCursor(ctx.editorInfo, pos[0], pos[1]);
1004
+ };
1005
+
1006
+ // --- Dispatch ---
1007
+
1008
+ const handleKey = (key, ctx) => {
1009
+ if (key >= "1" && key <= "9") {
1010
+ countBuffer += key;
696
1011
  return true;
697
1012
  }
698
-
699
- if (key === "P") {
700
- if (register !== null) {
701
- if (typeof register === "string") {
702
- const repeated = register.repeat(count);
703
- replaceRange(editorInfo, [line, char], [line, char], repeated);
704
- moveBlockCursor(editorInfo, line, char);
705
- } else {
706
- const block = register.join("\n");
707
- const parts = [];
708
- for (let i = 0; i < count; i++) parts.push(block);
709
- const insertText = parts.join("\n") + "\n";
710
- replaceRange(editorInfo, [line, 0], [line, 0], insertText);
711
- moveBlockCursor(editorInfo, line, 0);
712
- }
713
- }
1013
+ if (key === "0" && countBuffer !== "") {
1014
+ countBuffer += key;
714
1015
  return true;
715
1016
  }
716
1017
 
717
- if (key === "J") {
718
- const lineCount = rep.lines.length();
719
- const joins = Math.min(count, lineCount - 1 - line);
720
- let cursorChar = lineText.length;
721
- for (let i = 0; i < joins; i++) {
722
- const curLineText = getLineText(rep, line);
723
- const nextLineText = getLineText(rep, line + 1);
724
- const trimmedNext = nextLineText.replace(/^\s+/, "");
725
- const separator = curLineText.length === 0 ? "" : " ";
726
- if (i === 0) cursorChar = curLineText.length;
727
- replaceRange(
728
- editorInfo,
729
- [line, curLineText.length],
730
- [line + 1, nextLineText.length],
731
- separator + trimmedNext,
732
- );
733
- }
734
- moveBlockCursor(editorInfo, line, cursorChar);
735
- return true;
1018
+ if (countBuffer !== "") {
1019
+ pendingCount = parseInt(countBuffer, 10);
1020
+ countBuffer = "";
736
1021
  }
1022
+ ctx.count = pendingCount !== null ? pendingCount : 1;
1023
+ ctx.hasCount = pendingCount !== null;
737
1024
 
738
- if (key === "~") {
739
- if (lineText.length > 0) {
740
- const toggleCount = Math.min(count, lineText.length - char);
741
- const slice = lineText.slice(char, char + toggleCount);
742
- let toggled = "";
743
- for (let i = 0; i < slice.length; i++) {
744
- const ch = slice[i];
745
- toggled +=
746
- ch === ch.toLowerCase() ? ch.toUpperCase() : ch.toLowerCase();
747
- }
748
- replaceRange(
749
- editorInfo,
750
- [line, char],
751
- [line, char + toggleCount],
752
- toggled,
753
- );
754
- const newChar = Math.min(char + toggleCount, lineText.length - 1);
755
- moveBlockCursor(editorInfo, line, newChar);
756
- }
1025
+ if (pendingKey !== null && parameterized[pendingKey]) {
1026
+ const handler = parameterized[pendingKey];
1027
+ pendingKey = null;
1028
+ handler(key, ctx);
1029
+ pendingCount = null;
757
1030
  return true;
758
1031
  }
759
1032
 
760
- if (key === "D") {
761
- setRegister(lineText.slice(char));
762
- replaceRange(editorInfo, [line, char], [line, lineText.length], "");
763
- const newLineText = getLineText(rep, line);
764
- moveBlockCursor(editorInfo, line, clampChar(char, newLineText));
1033
+ const map = commands[mode];
1034
+ const seq = pendingKey !== null ? pendingKey + key : key;
1035
+
1036
+ if (map[seq]) {
1037
+ pendingKey = null;
1038
+ map[seq](ctx);
1039
+ if (pendingKey === null) pendingCount = null;
765
1040
  return true;
766
1041
  }
767
1042
 
768
- if (key === "C") {
769
- setRegister(lineText.slice(char));
770
- replaceRange(editorInfo, [line, char], [line, lineText.length], "");
771
- moveCursor(editorInfo, line, char);
772
- setInsertMode(true);
1043
+ const isPrefix = Object.keys(map).some(
1044
+ (k) => k.startsWith(seq) && k.length > seq.length,
1045
+ );
1046
+ if (isPrefix) {
1047
+ pendingKey = seq;
773
1048
  return true;
774
1049
  }
775
1050
 
776
- if (key === "s") {
777
- setRegister(lineText.slice(char, char + 1));
778
- replaceRange(
779
- editorInfo,
780
- [line, char],
781
- [line, Math.min(char + count, lineText.length)],
782
- "",
783
- );
784
- moveCursor(editorInfo, line, char);
785
- setInsertMode(true);
1051
+ if (
1052
+ pendingKey &&
1053
+ (key === "i" || key === "a") &&
1054
+ Object.keys(map).some((k) => k.startsWith(pendingKey + key))
1055
+ ) {
1056
+ pendingKey = pendingKey + key;
786
1057
  return true;
787
1058
  }
788
1059
 
789
- if (key === "S") {
790
- setRegister(lineText);
791
- replaceRange(editorInfo, [line, 0], [line, lineText.length], "");
792
- moveCursor(editorInfo, line, 0);
793
- setInsertMode(true);
1060
+ if (
1061
+ (mode === "visual-char" || mode === "visual-line") &&
1062
+ (pendingKey === "i" || pendingKey === "a")
1063
+ ) {
1064
+ const type = pendingKey;
1065
+ pendingKey = null;
1066
+ const range = resolveTextObject(
1067
+ key,
1068
+ type,
1069
+ ctx.line,
1070
+ ctx.lineText,
1071
+ ctx.char,
1072
+ ctx.rep,
1073
+ );
1074
+ if (range) {
1075
+ visualAnchor = [range.startLine, range.startChar];
1076
+ visualCursor = [range.endLine, range.endChar];
1077
+ updateVisualSelection(ctx.editorInfo, ctx.rep);
1078
+ }
794
1079
  return true;
795
1080
  }
796
1081
 
797
1082
  pendingKey = null;
798
- return false;
1083
+ pendingCount = null;
1084
+ return true;
799
1085
  };
800
1086
 
801
- // --- Exports ---
1087
+ // --- Etherpad hooks ---
802
1088
 
803
1089
  exports.aceEditorCSS = () => ["ep_vim/static/css/vim.css"];
804
1090
 
@@ -827,102 +1113,76 @@ exports.postAceInit = (_hookName, { ace }) => {
827
1113
  exports.aceKeyEvent = (_hookName, { evt, rep, editorInfo }) => {
828
1114
  if (!vimEnabled) return false;
829
1115
  if (evt.type !== "keydown") return false;
1116
+
1117
+ const isBrowserShortcut =
1118
+ (evt.ctrlKey || evt.metaKey) &&
1119
+ (evt.key === "x" || evt.key === "c" || evt.key === "v" || evt.key === "r");
1120
+ if (isBrowserShortcut) return false;
1121
+
830
1122
  currentRep = rep;
831
- if (!editorDoc) {
832
- editorDoc = evt.target.ownerDocument;
833
- setInsertMode(insertMode);
834
- }
1123
+ if (!editorDoc) editorDoc = evt.target.ownerDocument;
835
1124
 
836
1125
  if (evt.key === "Escape") {
837
- if (insertMode) {
838
- setInsertMode(false);
839
- const [line, char] = rep.selStart;
840
- moveBlockCursor(editorInfo, line, Math.max(0, char - 1));
841
- }
842
- if (visualMode !== null) {
1126
+ desiredColumn = null;
1127
+ if (mode === "visual-line") {
1128
+ const line = Math.min(visualAnchor[0], visualCursor[0]);
1129
+ mode = "normal";
1130
+ moveBlockCursor(editorInfo, line, 0);
1131
+ } else if (mode === "visual-char") {
843
1132
  const [vLine, vChar] = visualCursor;
844
- setVisualMode(null);
1133
+ mode = "normal";
845
1134
  moveBlockCursor(editorInfo, vLine, vChar);
1135
+ } else if (mode === "insert") {
1136
+ mode = "normal";
1137
+ const [curLine, curChar] = rep.selStart;
1138
+ moveBlockCursor(editorInfo, curLine, Math.max(0, curChar - 1));
1139
+ } else {
1140
+ mode = "normal";
1141
+ const [curLine, curChar] = rep.selStart;
1142
+ moveBlockCursor(editorInfo, curLine, curChar);
846
1143
  }
847
- countBuffer = "";
848
1144
  pendingKey = null;
849
- pendingOperator = null;
850
1145
  pendingCount = null;
851
- desiredColumn = null;
1146
+ countBuffer = "";
852
1147
  evt.preventDefault();
853
1148
  return true;
854
1149
  }
855
1150
 
856
- if (insertMode) return false;
857
-
858
- if (pendingKey !== null || pendingOperator !== null) {
859
- const handled = handleKey(rep, editorInfo, evt.key);
860
- evt.preventDefault();
861
- return handled || true;
862
- }
863
-
864
- if (visualMode === null) {
865
- if (evt.key === "i") {
866
- const [line, char] = rep.selStart;
867
- desiredColumn = null;
868
- moveCursor(editorInfo, line, char);
869
- setInsertMode(true);
870
- evt.preventDefault();
871
- return true;
872
- }
873
-
874
- if (evt.key === "a") {
875
- const [line, char] = rep.selStart;
876
- const lineText = getLineText(rep, line);
877
- desiredColumn = null;
878
- moveCursor(editorInfo, line, Math.min(char + 1, lineText.length));
879
- setInsertMode(true);
880
- evt.preventDefault();
881
- return true;
882
- }
883
-
884
- if (evt.key === "A") {
885
- const [line] = rep.selStart;
886
- const lineText = getLineText(rep, line);
887
- desiredColumn = null;
888
- moveCursor(editorInfo, line, lineText.length);
889
- setInsertMode(true);
890
- evt.preventDefault();
891
- return true;
892
- }
893
-
894
- if (evt.key === "I") {
895
- const [line] = rep.selStart;
896
- const lineText = getLineText(rep, line);
897
- desiredColumn = null;
898
- moveCursor(editorInfo, line, firstNonBlank(lineText));
899
- setInsertMode(true);
1151
+ if (mode === "insert") return false;
1152
+
1153
+ if (searchMode) {
1154
+ if (evt.key === "Enter") {
1155
+ searchMode = false;
1156
+ const pattern = searchBuffer;
1157
+ lastSearch = { pattern, direction: searchDirection };
1158
+ const [curLine, curChar] = rep.selStart;
1159
+ const searchFunc =
1160
+ searchDirection === "/" ? searchForward : searchBackward;
1161
+ const pos = searchFunc(rep, curLine, curChar + 1, pattern);
1162
+ if (pos) moveBlockCursor(editorInfo, pos[0], pos[1]);
1163
+ searchBuffer = "";
900
1164
  evt.preventDefault();
901
1165
  return true;
902
- }
903
-
904
- if (evt.key === "V") {
905
- const [line] = rep.selStart;
906
- visualAnchor = [line, 0];
907
- visualCursor = [line, 0];
908
- setVisualMode("line");
909
- updateVisualSelection(editorInfo, rep);
1166
+ } else if (evt.key === "Escape") {
1167
+ searchMode = false;
1168
+ searchBuffer = "";
910
1169
  evt.preventDefault();
911
1170
  return true;
912
- }
913
-
914
- if (evt.key === "v") {
915
- const [line, char] = rep.selStart;
916
- visualAnchor = [line, char];
917
- visualCursor = [line, char];
918
- setVisualMode("char");
919
- updateVisualSelection(editorInfo, rep);
1171
+ } else if (evt.key.length === 1 && !evt.ctrlKey && !evt.metaKey) {
1172
+ searchBuffer += evt.key;
920
1173
  evt.preventDefault();
921
1174
  return true;
922
1175
  }
1176
+ return false;
923
1177
  }
924
1178
 
925
- const handled = handleKey(rep, editorInfo, evt.key);
926
- evt.preventDefault();
927
- return handled || true;
1179
+ const [line, char] =
1180
+ mode === "visual-line" || mode === "visual-char"
1181
+ ? visualCursor
1182
+ : rep.selStart;
1183
+ const lineText = rep.lines.atIndex(line).text;
1184
+ const ctx = { rep, editorInfo, line, char, lineText };
1185
+ const handled = handleKey(evt.key, ctx);
1186
+ if (handled) evt.preventDefault();
1187
+ return handled;
928
1188
  };