vim-prose 0.1.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.
Files changed (33) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -0
  3. package/dist/extensions/vim/commands.d.ts +26 -0
  4. package/dist/extensions/vim/commands.js +231 -0
  5. package/dist/extensions/vim/commands.js.map +1 -0
  6. package/dist/extensions/vim/index.d.ts +3 -0
  7. package/dist/extensions/vim/index.js +2 -0
  8. package/dist/extensions/vim/index.js.map +1 -0
  9. package/dist/extensions/vim/keyHandler.d.ts +6 -0
  10. package/dist/extensions/vim/keyHandler.js +1400 -0
  11. package/dist/extensions/vim/keyHandler.js.map +1 -0
  12. package/dist/extensions/vim/motions.d.ts +80 -0
  13. package/dist/extensions/vim/motions.js +437 -0
  14. package/dist/extensions/vim/motions.js.map +1 -0
  15. package/dist/extensions/vim/operators.d.ts +36 -0
  16. package/dist/extensions/vim/operators.js +350 -0
  17. package/dist/extensions/vim/operators.js.map +1 -0
  18. package/dist/extensions/vim/state.d.ts +8 -0
  19. package/dist/extensions/vim/state.js +174 -0
  20. package/dist/extensions/vim/state.js.map +1 -0
  21. package/dist/extensions/vim/tiptap.d.ts +17 -0
  22. package/dist/extensions/vim/tiptap.js +49 -0
  23. package/dist/extensions/vim/types.d.ts +55 -0
  24. package/dist/extensions/vim/types.js +29 -0
  25. package/dist/extensions/vim/types.js.map +1 -0
  26. package/dist/extensions/vim/utils.d.ts +76 -0
  27. package/dist/extensions/vim/utils.js +224 -0
  28. package/dist/extensions/vim/utils.js.map +1 -0
  29. package/dist/extensions/vim/vim-mode.css +81 -0
  30. package/dist/extensions/vim/visual.d.ts +15 -0
  31. package/dist/extensions/vim/visual.js +58 -0
  32. package/dist/extensions/vim/visual.js.map +1 -0
  33. package/package.json +64 -0
@@ -0,0 +1,1400 @@
1
+ import { TextSelection, Selection, } from 'prosemirror-state';
2
+ import { motionLeft, motionRight, motionDown, motionUp, motionLineStart, motionFirstNonBlank, motionLineEnd, motionDocStart, motionDocEnd, motionWordForward, motionWordBackward, motionFindCharForward, motionFindCharBackward, motionTillCharForward, motionTillCharBackward, motionHalfPageDown, motionHalfPageUp, motionFullPageDown, motionFullPageUp, } from './motions';
3
+ import { resolveTextObject, executeDelete, executeYank, executeChange, deleteLines, yankLines, changeLines, } from './operators';
4
+ import { deleteChar, pasteAfter, pasteBefore, openLineBelow, openLineAbove, joinLines, } from './commands';
5
+ import { updateVisualSelection, getVisualRange } from './visual';
6
+ import { lineStartAt, lineEndAt, firstNonBlank, findAllMatches, wordUnderCursor, } from './utils';
7
+ function clearPendingState(vimState) {
8
+ vimState.count = null;
9
+ vimState.operator = null;
10
+ vimState.findPending = false;
11
+ vimState.findMotion = null;
12
+ vimState.ggPending = false;
13
+ vimState.goalColumn = null;
14
+ vimState.zzPending = false;
15
+ vimState.shiftRightPending = false;
16
+ vimState.shiftLeftPending = false;
17
+ vimState.markPending = false;
18
+ vimState.gotoMarkPending = false;
19
+ }
20
+ function getEffectiveCount(vimState) {
21
+ return vimState.count ?? 1;
22
+ }
23
+ /**
24
+ * Apply a motion N times, returning the final position.
25
+ */
26
+ function applyMotionNTimes(state, pos, count, motionFn) {
27
+ let current = pos;
28
+ for (let i = 0; i < count; i++) {
29
+ current = motionFn(state, current);
30
+ }
31
+ return current;
32
+ }
33
+ /**
34
+ * Move cursor to position, clamping to valid range.
35
+ */
36
+ function moveCursor(state, pos) {
37
+ const clamped = Math.max(0, Math.min(pos, state.doc.content.size));
38
+ const tr = state.tr;
39
+ try {
40
+ tr.setSelection(TextSelection.create(tr.doc, clamped));
41
+ }
42
+ catch {
43
+ // If position is invalid (e.g. inside a node boundary), try to find nearest valid position
44
+ try {
45
+ const $pos = state.doc.resolve(clamped);
46
+ tr.setSelection(TextSelection.create(tr.doc, $pos.pos));
47
+ }
48
+ catch {
49
+ // leave selection unchanged
50
+ }
51
+ }
52
+ tr.scrollIntoView();
53
+ return tr;
54
+ }
55
+ /**
56
+ * Center the cursor vertically within the editor's scroll container.
57
+ * Only adjusts the editor's own scrollTop — never scrolls the outer page.
58
+ */
59
+ function centerCursorInEditor(view, pos) {
60
+ try {
61
+ const coords = view.coordsAtPos(pos);
62
+ const dom = view.dom;
63
+ const rect = dom.getBoundingClientRect();
64
+ const cursorFromTop = coords.top - rect.top;
65
+ const centerTarget = rect.height / 2;
66
+ dom.scrollTop += cursorFromTop - centerTarget;
67
+ }
68
+ catch {
69
+ // ignore
70
+ }
71
+ }
72
+ /**
73
+ * Handle operator + motion/text-object combination.
74
+ */
75
+ function handleOperatorMotion(state, vimState, from, to, linewise = false) {
76
+ const op = vimState.operator;
77
+ if (!op)
78
+ return null;
79
+ // Ensure from < to
80
+ const [rangeFrom, rangeTo] = from <= to ? [from, to] : [to, from];
81
+ let tr;
82
+ switch (op) {
83
+ case 'd':
84
+ tr = executeDelete(state, rangeFrom, rangeTo, vimState, linewise);
85
+ break;
86
+ case 'y':
87
+ executeYank(state, rangeFrom, rangeTo, vimState, linewise);
88
+ tr = state.tr; // No document change
89
+ break;
90
+ case 'c':
91
+ tr = executeChange(state, rangeFrom, rangeTo, vimState, linewise);
92
+ break;
93
+ default:
94
+ return null;
95
+ }
96
+ clearPendingState(vimState);
97
+ tr.scrollIntoView();
98
+ return tr;
99
+ }
100
+ /**
101
+ * Process a motion key and return the new position (or null if not a motion key).
102
+ */
103
+ function resolveMotionKey(state, pos, key, count, ctrlKey, goalColumn) {
104
+ if (ctrlKey) {
105
+ switch (key) {
106
+ case 'd':
107
+ return motionHalfPageDown(state, pos);
108
+ case 'u':
109
+ return motionHalfPageUp(state, pos);
110
+ case 'f':
111
+ return motionFullPageDown(state, pos);
112
+ case 'b':
113
+ return motionFullPageUp(state, pos);
114
+ default:
115
+ return null;
116
+ }
117
+ }
118
+ switch (key) {
119
+ case 'h':
120
+ return applyMotionNTimes(state, pos, count, motionLeft);
121
+ case 'l':
122
+ return applyMotionNTimes(state, pos, count, motionRight);
123
+ case 'j': {
124
+ let current = pos;
125
+ for (let i = 0; i < count; i++) {
126
+ current = motionDown(state, current, goalColumn);
127
+ }
128
+ return current;
129
+ }
130
+ case 'k': {
131
+ let current = pos;
132
+ for (let i = 0; i < count; i++) {
133
+ current = motionUp(state, current, goalColumn);
134
+ }
135
+ return current;
136
+ }
137
+ case '0':
138
+ return motionLineStart(state, pos);
139
+ case '^':
140
+ return motionFirstNonBlank(state);
141
+ case '$':
142
+ return motionLineEnd(state, pos);
143
+ case 'G':
144
+ return motionDocEnd(state);
145
+ case 'w':
146
+ return applyMotionNTimes(state, pos, count, motionWordForward);
147
+ case 'b':
148
+ return applyMotionNTimes(state, pos, count, motionWordBackward);
149
+ default:
150
+ return null;
151
+ }
152
+ }
153
+ function startInsertTracking(vimState, action) {
154
+ vimState.lastAction = action;
155
+ vimState.isTrackingInsert = true;
156
+ vimState.insertTextBuffer = '';
157
+ }
158
+ function replayLastAction(view, vimState, commands) {
159
+ const action = vimState.lastAction;
160
+ if (!action)
161
+ return;
162
+ const state = view.state;
163
+ const pos = state.selection.$head.pos;
164
+ const count = vimState.count ?? action.count;
165
+ switch (action.type) {
166
+ case 'command': {
167
+ switch (action.key) {
168
+ case 'x': {
169
+ const tr = deleteChar(state, pos, vimState, count);
170
+ view.dispatch(tr);
171
+ break;
172
+ }
173
+ case 'p': {
174
+ const tr = pasteAfter(state, pos, vimState, count);
175
+ view.dispatch(tr);
176
+ break;
177
+ }
178
+ case 'P': {
179
+ const tr = pasteBefore(state, pos, vimState, count);
180
+ view.dispatch(tr);
181
+ break;
182
+ }
183
+ case 'J': {
184
+ const tr = joinLines(state, pos, count);
185
+ view.dispatch(tr);
186
+ break;
187
+ }
188
+ case 'D': {
189
+ const endPos = lineEndAt(state, pos);
190
+ if (pos < endPos) {
191
+ const tr = executeDelete(state, pos, endPos, vimState, false);
192
+ view.dispatch(tr);
193
+ }
194
+ break;
195
+ }
196
+ case '>>': {
197
+ for (let i = 0; i < count; i++) {
198
+ commands.indent?.();
199
+ }
200
+ break;
201
+ }
202
+ case '<<': {
203
+ for (let i = 0; i < count; i++) {
204
+ commands.outdent?.();
205
+ }
206
+ break;
207
+ }
208
+ }
209
+ break;
210
+ }
211
+ case 'operator-linewise': {
212
+ switch (action.operator) {
213
+ case 'd': {
214
+ const tr = deleteLines(state, pos, count, vimState);
215
+ tr.scrollIntoView();
216
+ view.dispatch(tr);
217
+ break;
218
+ }
219
+ case 'c': {
220
+ const tr = changeLines(state, pos, count, vimState);
221
+ tr.scrollIntoView();
222
+ view.dispatch(tr);
223
+ if (action.insertedText) {
224
+ const ns = view.state;
225
+ const itr = ns.tr.insertText(action.insertedText, ns.selection.$head.pos);
226
+ view.dispatch(itr);
227
+ vimState.mode = 'normal';
228
+ const fs = view.state;
229
+ const fp = fs.selection.$head.pos;
230
+ const ls = lineStartAt(fs, fp);
231
+ view.dispatch(moveCursor(fs, fp > ls ? fp - 1 : fp));
232
+ }
233
+ break;
234
+ }
235
+ }
236
+ break;
237
+ }
238
+ case 'operator-motion': {
239
+ if (!action.operator || !action.motion)
240
+ break;
241
+ let targetPos = null;
242
+ if (action.findMotion && action.findChar) {
243
+ let current = pos;
244
+ for (let i = 0; i < count; i++) {
245
+ let result = null;
246
+ switch (action.findMotion) {
247
+ case 'f':
248
+ result = motionFindCharForward(state, current, action.findChar);
249
+ break;
250
+ case 'F':
251
+ result = motionFindCharBackward(state, current, action.findChar);
252
+ break;
253
+ case 't':
254
+ result = motionTillCharForward(state, current, action.findChar);
255
+ break;
256
+ case 'T':
257
+ result = motionTillCharBackward(state, current, action.findChar);
258
+ break;
259
+ }
260
+ if (result === null)
261
+ break;
262
+ current = result;
263
+ }
264
+ targetPos = current !== pos ? current : null;
265
+ }
266
+ else if (action.motion === 'gg') {
267
+ targetPos = motionDocStart(state);
268
+ }
269
+ else {
270
+ targetPos = resolveMotionKey(state, pos, action.motion, count, false);
271
+ }
272
+ if (targetPos !== null) {
273
+ let from = pos;
274
+ let to = targetPos;
275
+ if (action.findMotion === 'f' || action.findMotion === 't') {
276
+ to = targetPos + 1;
277
+ }
278
+ else if (action.findMotion === 'F' || action.findMotion === 'T') {
279
+ from = targetPos;
280
+ to = pos;
281
+ }
282
+ vimState.operator = action.operator;
283
+ const tr = handleOperatorMotion(state, vimState, from, to, false);
284
+ if (tr) {
285
+ tr.scrollIntoView();
286
+ view.dispatch(tr);
287
+ }
288
+ if (action.operator === 'c' && action.insertedText) {
289
+ const ns = view.state;
290
+ const itr = ns.tr.insertText(action.insertedText, ns.selection.$head.pos);
291
+ view.dispatch(itr);
292
+ vimState.mode = 'normal';
293
+ const fs = view.state;
294
+ const fp = fs.selection.$head.pos;
295
+ const ls = lineStartAt(fs, fp);
296
+ view.dispatch(moveCursor(fs, fp > ls ? fp - 1 : fp));
297
+ }
298
+ }
299
+ break;
300
+ }
301
+ case 'operator-textobject': {
302
+ if (!action.operator || !action.textObject)
303
+ break;
304
+ const result = resolveTextObject(state, pos, action.textObject.type, action.textObject.object);
305
+ if (result) {
306
+ vimState.operator = action.operator;
307
+ const tr = handleOperatorMotion(state, vimState, result.from, result.to, false);
308
+ if (tr) {
309
+ tr.scrollIntoView();
310
+ view.dispatch(tr);
311
+ }
312
+ if (action.operator === 'c' && action.insertedText) {
313
+ const ns = view.state;
314
+ const itr = ns.tr.insertText(action.insertedText, ns.selection.$head.pos);
315
+ view.dispatch(itr);
316
+ vimState.mode = 'normal';
317
+ const fs = view.state;
318
+ const fp = fs.selection.$head.pos;
319
+ const ls = lineStartAt(fs, fp);
320
+ view.dispatch(moveCursor(fs, fp > ls ? fp - 1 : fp));
321
+ }
322
+ }
323
+ break;
324
+ }
325
+ case 'insert-command': {
326
+ switch (action.key) {
327
+ case 'o': {
328
+ const tr = openLineBelow(state, pos, vimState);
329
+ view.dispatch(tr);
330
+ break;
331
+ }
332
+ case 'O': {
333
+ const tr = openLineAbove(state, pos, vimState);
334
+ view.dispatch(tr);
335
+ break;
336
+ }
337
+ case 'i': {
338
+ vimState.mode = 'insert';
339
+ view.dispatch(state.tr);
340
+ break;
341
+ }
342
+ case 'a': {
343
+ vimState.mode = 'insert';
344
+ const newPos = Math.min(pos + 1, lineEndAt(state, pos));
345
+ view.dispatch(moveCursor(state, newPos));
346
+ break;
347
+ }
348
+ case 'A': {
349
+ vimState.mode = 'insert';
350
+ const endPos = lineEndAt(state, pos);
351
+ view.dispatch(moveCursor(state, endPos));
352
+ break;
353
+ }
354
+ case 'I': {
355
+ vimState.mode = 'insert';
356
+ const fnbPos = firstNonBlank(state);
357
+ view.dispatch(moveCursor(state, fnbPos));
358
+ break;
359
+ }
360
+ case 'C': {
361
+ const endPos = lineEndAt(state, pos);
362
+ if (pos < endPos) {
363
+ const tr = executeChange(state, pos, endPos, vimState, false);
364
+ view.dispatch(tr);
365
+ }
366
+ else {
367
+ vimState.mode = 'insert';
368
+ }
369
+ break;
370
+ }
371
+ }
372
+ // Insert the recorded text and return to normal mode
373
+ if (action.insertedText) {
374
+ const ns = view.state;
375
+ const itr = ns.tr.insertText(action.insertedText, ns.selection.$head.pos);
376
+ view.dispatch(itr);
377
+ vimState.mode = 'normal';
378
+ const fs = view.state;
379
+ const fp = fs.selection.$head.pos;
380
+ const ls = lineStartAt(fs, fp);
381
+ view.dispatch(moveCursor(fs, fp > ls ? fp - 1 : fp));
382
+ }
383
+ break;
384
+ }
385
+ }
386
+ }
387
+ /**
388
+ * Main key handler for the vim plugin.
389
+ */
390
+ export function handleKeyDown(view, event, vimState, commands) {
391
+ const state = view.state;
392
+ // Clear status message from previous action
393
+ vimState.statusMessage = '';
394
+ // In visual modes, use the tracked visual head (not $head.pos which is the exclusive selection end)
395
+ const pos = (vimState.mode === 'visual' || vimState.mode === 'visual-line') &&
396
+ vimState.visualHead !== null
397
+ ? vimState.visualHead
398
+ : state.selection.$head.pos;
399
+ const key = event.key;
400
+ // const ctrlKey = event.ctrlKey || event.metaKey
401
+ const ctrlKey = event.ctrlKey;
402
+ // ── SEARCH ACTIVE (typing search query) ──
403
+ if (vimState.searchActive) {
404
+ if (key === 'Escape' || (ctrlKey && key === 'c')) {
405
+ vimState.searchActive = false;
406
+ vimState.searchQuery = '';
407
+ view.dispatch(state.tr); // trigger decoration update
408
+ return true;
409
+ }
410
+ if (key === 'Enter') {
411
+ vimState.searchActive = false;
412
+ vimState.searchTerm = vimState.searchQuery;
413
+ vimState.searchQuery = '';
414
+ vimState.searchWholeWord = false;
415
+ vimState.searchHighlightsVisible = true;
416
+ // Find and go to first match
417
+ const matches = findAllMatches(state, vimState.searchTerm, false);
418
+ if (matches.length > 0) {
419
+ let idx = matches.findIndex((m) => m > pos);
420
+ if (idx === -1)
421
+ idx = 0; // wrap around
422
+ vimState.statusMessage = `${idx + 1}/${matches.length}`;
423
+ view.dispatch(moveCursor(state, matches[idx]));
424
+ centerCursorInEditor(view, matches[idx]);
425
+ }
426
+ else {
427
+ vimState.statusMessage = 'pattern not found';
428
+ view.dispatch(state.tr);
429
+ }
430
+ return true;
431
+ }
432
+ if (key === 'Backspace') {
433
+ vimState.searchQuery = vimState.searchQuery.slice(0, -1);
434
+ view.dispatch(state.tr); // trigger decoration update
435
+ return true;
436
+ }
437
+ if (key.length === 1 && !ctrlKey) {
438
+ vimState.searchQuery += key;
439
+ view.dispatch(state.tr); // trigger decoration update
440
+ return true;
441
+ }
442
+ return true; // consume all keys in search mode
443
+ }
444
+ // ── INSERT MODE ──
445
+ if (vimState.mode === 'insert') {
446
+ if (key === 'Escape' || (ctrlKey && key === 'c')) {
447
+ vimState.mode = 'normal';
448
+ clearPendingState(vimState);
449
+ // Finalize insert text tracking for dot repeat
450
+ if (vimState.isTrackingInsert && vimState.lastAction) {
451
+ vimState.lastAction.insertedText = vimState.insertTextBuffer;
452
+ vimState.isTrackingInsert = false;
453
+ vimState.insertTextBuffer = '';
454
+ }
455
+ // Move cursor one left (vim behavior) but don't cross line boundary
456
+ const lineS = lineStartAt(state, pos);
457
+ const newPos = pos > lineS ? pos - 1 : pos;
458
+ view.dispatch(moveCursor(state, newPos));
459
+ return true;
460
+ }
461
+ // Track backspace for dot repeat
462
+ if (key === 'Backspace' && vimState.isTrackingInsert) {
463
+ vimState.insertTextBuffer = vimState.insertTextBuffer.slice(0, -1);
464
+ }
465
+ return false; // Let all other keys pass through in insert mode
466
+ }
467
+ // ── ESC / CTRL-C (normal/visual) ──
468
+ if (key === 'Escape' || (ctrlKey && key === 'c')) {
469
+ if (vimState.mode === 'visual' || vimState.mode === 'visual-line') {
470
+ const restorePos = pos;
471
+ vimState.mode = 'normal';
472
+ vimState.visualAnchor = null;
473
+ vimState.visualHead = null;
474
+ clearPendingState(vimState);
475
+ vimState.searchHighlightsVisible = false;
476
+ // Collapse selection to the visual head position
477
+ view.dispatch(moveCursor(state, restorePos));
478
+ return true;
479
+ }
480
+ clearPendingState(vimState);
481
+ // Clear search highlights (searchTerm preserved for n/N)
482
+ vimState.searchHighlightsVisible = false;
483
+ view.dispatch(state.tr); // trigger decoration update
484
+ return true;
485
+ }
486
+ // ── TEXT OBJECT RESOLUTION (when operator + i/a is pending) ──
487
+ if (vimState._textObjectType) {
488
+ // Ignore modifier-only keys — wait for the actual character
489
+ if (key === 'Shift' ||
490
+ key === 'Control' ||
491
+ key === 'Alt' ||
492
+ key === 'Meta') {
493
+ return true;
494
+ }
495
+ const objectType = vimState._textObjectType;
496
+ delete vimState._textObjectType;
497
+ vimState.findPending = false;
498
+ if (key.length !== 1) {
499
+ clearPendingState(vimState);
500
+ return true;
501
+ }
502
+ const result = resolveTextObject(state, pos, objectType, key);
503
+ if (result) {
504
+ if (vimState.operator) {
505
+ const savedOp = vimState.operator;
506
+ const savedCount = getEffectiveCount(vimState);
507
+ const tr = handleOperatorMotion(state, vimState, result.from, result.to, false);
508
+ if (tr)
509
+ view.dispatch(tr);
510
+ // Record lastAction for dot repeat
511
+ if (savedOp !== 'y') {
512
+ const action = {
513
+ type: 'operator-textobject',
514
+ key: `${savedOp}${objectType}${key}`,
515
+ count: savedCount,
516
+ operator: savedOp,
517
+ textObject: { type: objectType, object: key },
518
+ };
519
+ if (savedOp === 'c') {
520
+ startInsertTracking(vimState, action);
521
+ }
522
+ else {
523
+ vimState.lastAction = action;
524
+ }
525
+ }
526
+ clearPendingState(vimState);
527
+ }
528
+ else if (vimState.mode === 'visual' ||
529
+ vimState.mode === 'visual-line') {
530
+ vimState.visualAnchor = result.from;
531
+ vimState.visualHead =
532
+ result.to > result.from ? result.to - 1 : result.from;
533
+ const tr = state.tr;
534
+ try {
535
+ tr.setSelection(TextSelection.create(tr.doc, result.from, result.to));
536
+ }
537
+ catch {
538
+ // leave as-is
539
+ }
540
+ view.dispatch(tr);
541
+ }
542
+ }
543
+ else {
544
+ clearPendingState(vimState);
545
+ }
546
+ return true;
547
+ }
548
+ // ── FIND PENDING (waiting for char after f/F/t/T) ──
549
+ if (vimState.findPending) {
550
+ // Ignore modifier-only keys — wait for the actual character
551
+ if (key === 'Shift' ||
552
+ key === 'Control' ||
553
+ key === 'Alt' ||
554
+ key === 'Meta') {
555
+ return true;
556
+ }
557
+ if (key.length !== 1) {
558
+ clearPendingState(vimState);
559
+ return true;
560
+ }
561
+ const count = getEffectiveCount(vimState);
562
+ let targetPos = null;
563
+ for (let i = 0; i < count; i++) {
564
+ const searchFrom = targetPos ?? pos;
565
+ let result = null;
566
+ switch (vimState.findMotion) {
567
+ case 'f':
568
+ result = motionFindCharForward(state, searchFrom, key);
569
+ break;
570
+ case 'F':
571
+ result = motionFindCharBackward(state, searchFrom, key);
572
+ break;
573
+ case 't':
574
+ result = motionTillCharForward(state, searchFrom, key);
575
+ break;
576
+ case 'T':
577
+ result = motionTillCharBackward(state, searchFrom, key);
578
+ break;
579
+ }
580
+ if (result === null)
581
+ break;
582
+ targetPos = result;
583
+ }
584
+ if (targetPos !== null) {
585
+ if (vimState.operator) {
586
+ const savedOp = vimState.operator;
587
+ const savedFindMotion = vimState.findMotion;
588
+ // For forward motions (f/t): range is [pos, targetPos+1)
589
+ // For backward motions (F/T): range is [targetPos, pos)
590
+ let rangeFrom, rangeTo;
591
+ if (vimState.findMotion === 'f' || vimState.findMotion === 't') {
592
+ rangeFrom = pos;
593
+ rangeTo = targetPos + 1; // inclusive of the target char for f, pos after t-stop for t
594
+ }
595
+ else {
596
+ rangeFrom = targetPos;
597
+ rangeTo = pos;
598
+ }
599
+ const tr = handleOperatorMotion(state, vimState, rangeFrom, rangeTo, false);
600
+ if (tr)
601
+ view.dispatch(tr);
602
+ // Record lastAction for dot repeat
603
+ if (savedOp !== 'y') {
604
+ const action = {
605
+ type: 'operator-motion',
606
+ key: `${savedOp}${savedFindMotion}${key}`,
607
+ count,
608
+ operator: savedOp,
609
+ motion: savedFindMotion || '',
610
+ findMotion: savedFindMotion || undefined,
611
+ findChar: key,
612
+ };
613
+ if (savedOp === 'c') {
614
+ startInsertTracking(vimState, action);
615
+ }
616
+ else {
617
+ vimState.lastAction = action;
618
+ }
619
+ }
620
+ }
621
+ else if (vimState.mode === 'visual' ||
622
+ vimState.mode === 'visual-line') {
623
+ const tr = state.tr;
624
+ updateVisualSelection(state, tr, vimState, targetPos);
625
+ vimState.visualHead = targetPos;
626
+ view.dispatch(tr);
627
+ }
628
+ else {
629
+ view.dispatch(moveCursor(state, targetPos));
630
+ }
631
+ }
632
+ vimState.findPending = false;
633
+ vimState.findMotion = null;
634
+ vimState.count = null;
635
+ if (!vimState.operator) {
636
+ // operator was already cleared by handleOperatorMotion
637
+ }
638
+ return true;
639
+ }
640
+ // ── DIGIT ACCUMULATION ── (skip when waiting for a mark character)
641
+ if (!vimState.markPending && !vimState.gotoMarkPending) {
642
+ if (key >= '1' && key <= '9') {
643
+ vimState.count = (vimState.count ?? 0) * 10 + parseInt(key);
644
+ return true;
645
+ }
646
+ if (key === '0' && vimState.count !== null) {
647
+ vimState.count = vimState.count * 10;
648
+ return true;
649
+ }
650
+ }
651
+ // ── GG PENDING ──
652
+ if (vimState.ggPending) {
653
+ if (key === 'g') {
654
+ vimState.ggPending = false;
655
+ const targetPos = motionDocStart(state);
656
+ if (vimState.operator) {
657
+ const tr = handleOperatorMotion(state, vimState, pos, targetPos, false);
658
+ if (tr)
659
+ view.dispatch(tr);
660
+ }
661
+ else if (vimState.mode === 'visual' ||
662
+ vimState.mode === 'visual-line') {
663
+ const tr = state.tr;
664
+ updateVisualSelection(state, tr, vimState, targetPos);
665
+ vimState.visualHead = targetPos;
666
+ view.dispatch(tr);
667
+ }
668
+ else {
669
+ view.dispatch(moveCursor(state, targetPos));
670
+ }
671
+ clearPendingState(vimState);
672
+ return true;
673
+ }
674
+ vimState.ggPending = false;
675
+ return true;
676
+ }
677
+ // ── ZZ PENDING ──
678
+ if (vimState.zzPending) {
679
+ if (key === 'z') {
680
+ vimState.zzPending = false;
681
+ centerCursorInEditor(view, pos);
682
+ clearPendingState(vimState);
683
+ return true;
684
+ }
685
+ vimState.zzPending = false;
686
+ clearPendingState(vimState);
687
+ return true;
688
+ }
689
+ // ── SHIFT RIGHT PENDING (>>) ──
690
+ if (vimState.shiftRightPending) {
691
+ if (key === '>') {
692
+ vimState.shiftRightPending = false;
693
+ const indentCount = getEffectiveCount(vimState);
694
+ for (let i = 0; i < indentCount; i++) {
695
+ commands.indent?.();
696
+ }
697
+ vimState.lastAction = { type: 'command', key: '>>', count: indentCount };
698
+ clearPendingState(vimState);
699
+ return true;
700
+ }
701
+ vimState.shiftRightPending = false;
702
+ clearPendingState(vimState);
703
+ return true;
704
+ }
705
+ // ── SHIFT LEFT PENDING (<<) ──
706
+ if (vimState.shiftLeftPending) {
707
+ if (key === '<') {
708
+ vimState.shiftLeftPending = false;
709
+ const outdentCount = getEffectiveCount(vimState);
710
+ for (let i = 0; i < outdentCount; i++) {
711
+ commands.outdent?.();
712
+ }
713
+ vimState.lastAction = { type: 'command', key: '<<', count: outdentCount };
714
+ clearPendingState(vimState);
715
+ return true;
716
+ }
717
+ vimState.shiftLeftPending = false;
718
+ clearPendingState(vimState);
719
+ return true;
720
+ }
721
+ // ── MARK PENDING (m + char) ──
722
+ if (vimState.markPending) {
723
+ // Ignore modifier-only keys
724
+ if (key === 'Shift' ||
725
+ key === 'Control' ||
726
+ key === 'Alt' ||
727
+ key === 'Meta') {
728
+ return true;
729
+ }
730
+ if (key.length === 1 && /[a-zA-Z0-9]/.test(key)) {
731
+ vimState.marks[key] = pos;
732
+ vimState.statusMessage = `mark ${key} set`;
733
+ }
734
+ vimState.markPending = false;
735
+ clearPendingState(vimState);
736
+ view.dispatch(state.tr); // trigger status update
737
+ return true;
738
+ }
739
+ // ── GOTO MARK PENDING (' + char) ──
740
+ if (vimState.gotoMarkPending) {
741
+ // Ignore modifier-only keys
742
+ if (key === 'Shift' ||
743
+ key === 'Control' ||
744
+ key === 'Alt' ||
745
+ key === 'Meta') {
746
+ return true;
747
+ }
748
+ if (key.length === 1 && /[a-zA-Z0-9]/.test(key)) {
749
+ const markPos = vimState.marks[key];
750
+ if (markPos !== undefined) {
751
+ const clampedPos = Math.min(markPos, state.doc.content.size);
752
+ vimState.statusMessage = `mark ${key}`;
753
+ if (vimState.operator) {
754
+ const tr = handleOperatorMotion(state, vimState, pos, clampedPos, false);
755
+ if (tr)
756
+ view.dispatch(tr);
757
+ }
758
+ else {
759
+ view.dispatch(moveCursor(state, clampedPos));
760
+ centerCursorInEditor(view, clampedPos);
761
+ }
762
+ }
763
+ else {
764
+ vimState.statusMessage = `mark ${key} not set`;
765
+ view.dispatch(state.tr); // trigger status update
766
+ }
767
+ }
768
+ vimState.gotoMarkPending = false;
769
+ clearPendingState(vimState);
770
+ return true;
771
+ }
772
+ // ── OPERATOR PENDING or VISUAL: i/a starts text object ──
773
+ if ((vimState.operator ||
774
+ vimState.mode === 'visual' ||
775
+ vimState.mode === 'visual-line') &&
776
+ (key === 'i' || key === 'a')) {
777
+ ;
778
+ vimState._textObjectType = key;
779
+ return true;
780
+ }
781
+ // ── NORMAL + VISUAL MODE DISPATCH ──
782
+ const count = getEffectiveCount(vimState);
783
+ // ── CTRL key combinations ──
784
+ if (ctrlKey) {
785
+ switch (key) {
786
+ case 'r': {
787
+ // Redo
788
+ for (let i = 0; i < count; i++) {
789
+ commands.redo();
790
+ }
791
+ clearPendingState(vimState);
792
+ return true;
793
+ }
794
+ case 'd':
795
+ case 'u':
796
+ case 'f':
797
+ case 'b': {
798
+ const targetPos = resolveMotionKey(state, pos, key, count, true);
799
+ if (targetPos !== null) {
800
+ if (vimState.mode === 'visual' || vimState.mode === 'visual-line') {
801
+ const tr = state.tr;
802
+ updateVisualSelection(state, tr, vimState, targetPos);
803
+ vimState.visualHead = targetPos;
804
+ view.dispatch(tr);
805
+ }
806
+ else {
807
+ view.dispatch(moveCursor(state, targetPos));
808
+ }
809
+ }
810
+ clearPendingState(vimState);
811
+ return true;
812
+ }
813
+ case 'c': {
814
+ // Ctrl-C acts as escape
815
+ return false; // Already handled above
816
+ }
817
+ }
818
+ return false;
819
+ }
820
+ // ── VISUAL MODE operations ──
821
+ if (vimState.mode === 'visual' || vimState.mode === 'visual-line') {
822
+ switch (key) {
823
+ case 'v': {
824
+ if (vimState.mode === 'visual') {
825
+ // Toggle off visual mode
826
+ vimState.mode = 'normal';
827
+ vimState.visualAnchor = null;
828
+ vimState.visualHead = null;
829
+ view.dispatch(moveCursor(state, pos));
830
+ }
831
+ else {
832
+ // Switch from visual-line to characterwise visual
833
+ vimState.mode = 'visual';
834
+ }
835
+ clearPendingState(vimState);
836
+ return true;
837
+ }
838
+ case 'V': {
839
+ if (vimState.mode === 'visual-line') {
840
+ // Toggle off visual-line mode
841
+ vimState.mode = 'normal';
842
+ vimState.visualAnchor = null;
843
+ vimState.visualHead = null;
844
+ view.dispatch(moveCursor(state, pos));
845
+ }
846
+ else {
847
+ // Switch to visual-line from characterwise
848
+ vimState.mode = 'visual-line';
849
+ const tr = state.tr;
850
+ updateVisualSelection(state, tr, vimState, pos);
851
+ view.dispatch(tr);
852
+ }
853
+ clearPendingState(vimState);
854
+ return true;
855
+ }
856
+ case 'y': {
857
+ const range = getVisualRange(state, vimState);
858
+ if (range) {
859
+ executeYank(state, range.from, range.to, vimState, range.linewise);
860
+ }
861
+ vimState.mode = 'normal';
862
+ vimState.visualAnchor = null;
863
+ vimState.visualHead = null;
864
+ // Position cursor at start of yanked range, using Selection.findFrom for robustness
865
+ let cursorPos = range ? range.from : pos;
866
+ if (range) {
867
+ try {
868
+ const $from = state.doc.resolve(range.from);
869
+ const sel = Selection.findFrom($from, 1, true);
870
+ if (sel)
871
+ cursorPos = sel.$from.pos;
872
+ }
873
+ catch {
874
+ /* keep cursorPos */
875
+ }
876
+ }
877
+ view.dispatch(moveCursor(state, cursorPos));
878
+ clearPendingState(vimState);
879
+ return true;
880
+ }
881
+ case 'd':
882
+ case 'x': {
883
+ const range = getVisualRange(state, vimState);
884
+ if (range) {
885
+ const tr = executeDelete(state, range.from, range.to, vimState, range.linewise);
886
+ vimState.mode = 'normal';
887
+ vimState.visualAnchor = null;
888
+ vimState.visualHead = null;
889
+ clearPendingState(vimState);
890
+ tr.scrollIntoView();
891
+ view.dispatch(tr);
892
+ }
893
+ return true;
894
+ }
895
+ case 'c': {
896
+ const range = getVisualRange(state, vimState);
897
+ if (range) {
898
+ const tr = executeChange(state, range.from, range.to, vimState, range.linewise);
899
+ vimState.visualAnchor = null;
900
+ vimState.visualHead = null;
901
+ clearPendingState(vimState);
902
+ tr.scrollIntoView();
903
+ view.dispatch(tr);
904
+ }
905
+ return true;
906
+ }
907
+ default: {
908
+ // Try as motion — prepare goalColumn for j/k
909
+ if (key === 'j' || key === 'k') {
910
+ if (vimState.goalColumn === null) {
911
+ try {
912
+ const $pos = state.doc.resolve(pos);
913
+ vimState.goalColumn = pos - $pos.start($pos.depth);
914
+ }
915
+ catch {
916
+ vimState.goalColumn = 0;
917
+ }
918
+ }
919
+ }
920
+ const savedGoal = key === 'j' || key === 'k' ? vimState.goalColumn : null;
921
+ const targetPos = resolveMotionKey(state, pos, key, count, false, vimState.goalColumn ?? undefined);
922
+ if (targetPos !== null) {
923
+ const tr = state.tr;
924
+ updateVisualSelection(state, tr, vimState, targetPos);
925
+ vimState.visualHead = targetPos;
926
+ view.dispatch(tr);
927
+ clearPendingState(vimState);
928
+ vimState.goalColumn = savedGoal;
929
+ return true;
930
+ }
931
+ // gg motion
932
+ if (key === 'g') {
933
+ vimState.ggPending = true;
934
+ return true;
935
+ }
936
+ // f/F/t/T
937
+ if (key === 'f' || key === 'F' || key === 't' || key === 'T') {
938
+ vimState.findPending = true;
939
+ vimState.findMotion = key;
940
+ return true;
941
+ }
942
+ }
943
+ }
944
+ return true; // Consume all keys in visual mode
945
+ }
946
+ // ── NORMAL MODE DISPATCH ──
947
+ // Operator pending mode: doubled operator = linewise
948
+ if (vimState.operator) {
949
+ if (key === vimState.operator) {
950
+ // dd, yy, cc — linewise operation
951
+ switch (vimState.operator) {
952
+ case 'd': {
953
+ const tr = deleteLines(state, pos, count, vimState);
954
+ vimState.lastAction = {
955
+ type: 'operator-linewise',
956
+ key: 'dd',
957
+ count,
958
+ operator: 'd',
959
+ };
960
+ clearPendingState(vimState);
961
+ tr.scrollIntoView();
962
+ view.dispatch(tr);
963
+ return true;
964
+ }
965
+ case 'y': {
966
+ yankLines(state, pos, count, vimState);
967
+ clearPendingState(vimState);
968
+ return true;
969
+ }
970
+ case 'c': {
971
+ const tr = changeLines(state, pos, count, vimState);
972
+ clearPendingState(vimState);
973
+ tr.scrollIntoView();
974
+ view.dispatch(tr);
975
+ startInsertTracking(vimState, {
976
+ type: 'operator-linewise',
977
+ key: 'cc',
978
+ count,
979
+ operator: 'c',
980
+ });
981
+ return true;
982
+ }
983
+ }
984
+ }
985
+ // Operator + motion — prepare goalColumn for j/k
986
+ const savedOp = vimState.operator;
987
+ if (key === 'j' || key === 'k') {
988
+ if (vimState.goalColumn === null) {
989
+ try {
990
+ const $pos = state.doc.resolve(pos);
991
+ vimState.goalColumn = pos - $pos.start($pos.depth);
992
+ }
993
+ catch {
994
+ vimState.goalColumn = 0;
995
+ }
996
+ }
997
+ }
998
+ const targetPos = resolveMotionKey(state, pos, key, count, false, vimState.goalColumn ?? undefined);
999
+ if (targetPos !== null) {
1000
+ let from = pos;
1001
+ let to = targetPos;
1002
+ // For $ motion with operator, include the end position
1003
+ if (key === '$') {
1004
+ to = targetPos;
1005
+ }
1006
+ // For w motion with operator, the range is from cursor to target
1007
+ if (key === 'w') {
1008
+ to = targetPos;
1009
+ }
1010
+ const tr = handleOperatorMotion(state, vimState, from, to, false);
1011
+ if (tr)
1012
+ view.dispatch(tr);
1013
+ // Record lastAction for dot repeat
1014
+ if (savedOp && savedOp !== 'y') {
1015
+ const action = {
1016
+ type: 'operator-motion',
1017
+ key: `${savedOp}${key}`,
1018
+ count,
1019
+ operator: savedOp,
1020
+ motion: key,
1021
+ };
1022
+ if (savedOp === 'c') {
1023
+ startInsertTracking(vimState, action);
1024
+ }
1025
+ else {
1026
+ vimState.lastAction = action;
1027
+ }
1028
+ }
1029
+ return true;
1030
+ }
1031
+ // Operator + gg
1032
+ if (key === 'g') {
1033
+ vimState.ggPending = true;
1034
+ return true;
1035
+ }
1036
+ // Operator + f/F/t/T
1037
+ if (key === 'f' || key === 'F' || key === 't' || key === 'T') {
1038
+ vimState.findPending = true;
1039
+ vimState.findMotion = key;
1040
+ return true;
1041
+ }
1042
+ // Operator + text object (i/a already handled above)
1043
+ return true;
1044
+ }
1045
+ // ── Normal mode key dispatch ──
1046
+ switch (key) {
1047
+ // Mode switching
1048
+ case 'i': {
1049
+ vimState.mode = 'insert';
1050
+ clearPendingState(vimState);
1051
+ view.dispatch(state.tr); // Trigger view update for mode change
1052
+ startInsertTracking(vimState, {
1053
+ type: 'insert-command',
1054
+ key: 'i',
1055
+ count: 1,
1056
+ });
1057
+ return true;
1058
+ }
1059
+ case 'I': {
1060
+ vimState.mode = 'insert';
1061
+ const fnbPos = firstNonBlank(state);
1062
+ view.dispatch(moveCursor(state, fnbPos));
1063
+ clearPendingState(vimState);
1064
+ startInsertTracking(vimState, {
1065
+ type: 'insert-command',
1066
+ key: 'I',
1067
+ count: 1,
1068
+ });
1069
+ return true;
1070
+ }
1071
+ case 'a': {
1072
+ vimState.mode = 'insert';
1073
+ // Move cursor one right (after current char)
1074
+ const newPos = Math.min(pos + 1, lineEndAt(state, pos));
1075
+ view.dispatch(moveCursor(state, newPos));
1076
+ clearPendingState(vimState);
1077
+ startInsertTracking(vimState, {
1078
+ type: 'insert-command',
1079
+ key: 'a',
1080
+ count: 1,
1081
+ });
1082
+ return true;
1083
+ }
1084
+ case 'A': {
1085
+ vimState.mode = 'insert';
1086
+ const endPos = lineEndAt(state, pos);
1087
+ view.dispatch(moveCursor(state, endPos));
1088
+ clearPendingState(vimState);
1089
+ startInsertTracking(vimState, {
1090
+ type: 'insert-command',
1091
+ key: 'A',
1092
+ count: 1,
1093
+ });
1094
+ return true;
1095
+ }
1096
+ case 'v': {
1097
+ vimState.mode = 'visual';
1098
+ vimState.visualAnchor = pos;
1099
+ vimState.visualHead = pos;
1100
+ clearPendingState(vimState);
1101
+ // Set initial selection (single character)
1102
+ const tr = state.tr;
1103
+ try {
1104
+ tr.setSelection(TextSelection.create(tr.doc, pos, Math.min(pos + 1, state.doc.content.size)));
1105
+ }
1106
+ catch {
1107
+ // leave as-is
1108
+ }
1109
+ view.dispatch(tr);
1110
+ return true;
1111
+ }
1112
+ case 'V': {
1113
+ vimState.mode = 'visual-line';
1114
+ vimState.visualAnchor = pos;
1115
+ vimState.visualHead = pos;
1116
+ clearPendingState(vimState);
1117
+ const tr = state.tr;
1118
+ updateVisualSelection(state, tr, vimState, pos);
1119
+ view.dispatch(tr);
1120
+ return true;
1121
+ }
1122
+ // Operators
1123
+ case 'd': {
1124
+ vimState.operator = 'd';
1125
+ return true;
1126
+ }
1127
+ case 'y': {
1128
+ vimState.operator = 'y';
1129
+ return true;
1130
+ }
1131
+ case 'c': {
1132
+ vimState.operator = 'c';
1133
+ return true;
1134
+ }
1135
+ // Linewise shortcuts
1136
+ case 'D': {
1137
+ // Delete to end of line
1138
+ const endPos = lineEndAt(state, pos);
1139
+ if (pos < endPos) {
1140
+ const tr = executeDelete(state, pos, endPos, vimState, false);
1141
+ view.dispatch(tr);
1142
+ }
1143
+ vimState.lastAction = { type: 'command', key: 'D', count: 1 };
1144
+ clearPendingState(vimState);
1145
+ return true;
1146
+ }
1147
+ case 'Y': {
1148
+ // Yank to end of line
1149
+ const endPos = lineEndAt(state, pos);
1150
+ executeYank(state, pos, endPos, vimState, false);
1151
+ clearPendingState(vimState);
1152
+ return true;
1153
+ }
1154
+ case 'C': {
1155
+ // Change to end of line
1156
+ const endPos = lineEndAt(state, pos);
1157
+ if (pos < endPos) {
1158
+ const tr = executeChange(state, pos, endPos, vimState, false);
1159
+ view.dispatch(tr);
1160
+ }
1161
+ else {
1162
+ vimState.mode = 'insert';
1163
+ }
1164
+ clearPendingState(vimState);
1165
+ startInsertTracking(vimState, {
1166
+ type: 'insert-command',
1167
+ key: 'C',
1168
+ count: 1,
1169
+ });
1170
+ return true;
1171
+ }
1172
+ // Editing commands
1173
+ case 'x': {
1174
+ const tr = deleteChar(state, pos, vimState, count);
1175
+ view.dispatch(tr);
1176
+ vimState.lastAction = { type: 'command', key: 'x', count };
1177
+ clearPendingState(vimState);
1178
+ return true;
1179
+ }
1180
+ case 'p': {
1181
+ const tr = pasteAfter(state, pos, vimState, count);
1182
+ view.dispatch(tr);
1183
+ vimState.lastAction = { type: 'command', key: 'p', count };
1184
+ clearPendingState(vimState);
1185
+ return true;
1186
+ }
1187
+ case 'P': {
1188
+ const tr = pasteBefore(state, pos, vimState, count);
1189
+ view.dispatch(tr);
1190
+ vimState.lastAction = { type: 'command', key: 'P', count };
1191
+ clearPendingState(vimState);
1192
+ return true;
1193
+ }
1194
+ case 'o': {
1195
+ const tr = openLineBelow(state, pos, vimState);
1196
+ view.dispatch(tr);
1197
+ clearPendingState(vimState);
1198
+ startInsertTracking(vimState, {
1199
+ type: 'insert-command',
1200
+ key: 'o',
1201
+ count: 1,
1202
+ });
1203
+ return true;
1204
+ }
1205
+ case 'O': {
1206
+ const tr = openLineAbove(state, pos, vimState);
1207
+ view.dispatch(tr);
1208
+ clearPendingState(vimState);
1209
+ startInsertTracking(vimState, {
1210
+ type: 'insert-command',
1211
+ key: 'O',
1212
+ count: 1,
1213
+ });
1214
+ return true;
1215
+ }
1216
+ case 'J': {
1217
+ const tr = joinLines(state, pos, count);
1218
+ view.dispatch(tr);
1219
+ vimState.lastAction = { type: 'command', key: 'J', count };
1220
+ clearPendingState(vimState);
1221
+ return true;
1222
+ }
1223
+ // Undo
1224
+ case 'u': {
1225
+ for (let i = 0; i < count; i++) {
1226
+ commands.undo();
1227
+ }
1228
+ clearPendingState(vimState);
1229
+ return true;
1230
+ }
1231
+ // Motions
1232
+ case 'j':
1233
+ case 'k': {
1234
+ if (vimState.goalColumn === null) {
1235
+ try {
1236
+ const $pos = state.doc.resolve(pos);
1237
+ vimState.goalColumn = pos - $pos.start($pos.depth);
1238
+ }
1239
+ catch {
1240
+ vimState.goalColumn = 0;
1241
+ }
1242
+ }
1243
+ const savedGoal = vimState.goalColumn;
1244
+ const targetPos = resolveMotionKey(state, pos, key, count, false, savedGoal);
1245
+ if (targetPos !== null) {
1246
+ view.dispatch(moveCursor(state, targetPos));
1247
+ }
1248
+ clearPendingState(vimState);
1249
+ vimState.goalColumn = savedGoal;
1250
+ return true;
1251
+ }
1252
+ case 'h':
1253
+ case 'l':
1254
+ case '^':
1255
+ case '$':
1256
+ case 'w':
1257
+ case 'b': {
1258
+ const targetPos = resolveMotionKey(state, pos, key, count, false);
1259
+ if (targetPos !== null) {
1260
+ view.dispatch(moveCursor(state, targetPos));
1261
+ }
1262
+ clearPendingState(vimState);
1263
+ return true;
1264
+ }
1265
+ case '0': {
1266
+ // 0 is motion to line start (only when not part of a count)
1267
+ const targetPos = motionLineStart(state, pos);
1268
+ view.dispatch(moveCursor(state, targetPos));
1269
+ clearPendingState(vimState);
1270
+ return true;
1271
+ }
1272
+ case 'G': {
1273
+ const targetPos = motionDocEnd(state);
1274
+ view.dispatch(moveCursor(state, targetPos));
1275
+ clearPendingState(vimState);
1276
+ return true;
1277
+ }
1278
+ case 'g': {
1279
+ vimState.ggPending = true;
1280
+ return true;
1281
+ }
1282
+ // Find/Till
1283
+ case 'f':
1284
+ case 'F':
1285
+ case 't':
1286
+ case 'T': {
1287
+ vimState.findPending = true;
1288
+ vimState.findMotion = key;
1289
+ return true;
1290
+ }
1291
+ // Center cursor
1292
+ case 'z': {
1293
+ vimState.zzPending = true;
1294
+ return true;
1295
+ }
1296
+ // Indent/Outdent
1297
+ case '>': {
1298
+ vimState.shiftRightPending = true;
1299
+ return true;
1300
+ }
1301
+ case '<': {
1302
+ vimState.shiftLeftPending = true;
1303
+ return true;
1304
+ }
1305
+ // Marks
1306
+ case 'm': {
1307
+ vimState.markPending = true;
1308
+ return true;
1309
+ }
1310
+ case "'": {
1311
+ vimState.gotoMarkPending = true;
1312
+ return true;
1313
+ }
1314
+ // Search
1315
+ case '/': {
1316
+ vimState.searchActive = true;
1317
+ vimState.searchQuery = '';
1318
+ view.dispatch(state.tr); // trigger decoration update for search bar
1319
+ return true;
1320
+ }
1321
+ case 'n': {
1322
+ if (vimState.searchTerm) {
1323
+ vimState.searchHighlightsVisible = true;
1324
+ const matches = findAllMatches(state, vimState.searchTerm, vimState.searchWholeWord);
1325
+ if (matches.length > 0) {
1326
+ let idx = matches.findIndex((m) => m > pos);
1327
+ if (idx === -1)
1328
+ idx = 0; // wrap around
1329
+ vimState.statusMessage = `${idx + 1}/${matches.length}`;
1330
+ view.dispatch(moveCursor(state, matches[idx]));
1331
+ centerCursorInEditor(view, matches[idx]);
1332
+ }
1333
+ else {
1334
+ vimState.statusMessage = 'pattern not found';
1335
+ view.dispatch(state.tr);
1336
+ }
1337
+ }
1338
+ clearPendingState(vimState);
1339
+ return true;
1340
+ }
1341
+ case 'N': {
1342
+ if (vimState.searchTerm) {
1343
+ vimState.searchHighlightsVisible = true;
1344
+ const matches = findAllMatches(state, vimState.searchTerm, vimState.searchWholeWord);
1345
+ if (matches.length > 0) {
1346
+ let idx = -1;
1347
+ for (let i = matches.length - 1; i >= 0; i--) {
1348
+ if (matches[i] < pos) {
1349
+ idx = i;
1350
+ break;
1351
+ }
1352
+ }
1353
+ if (idx === -1)
1354
+ idx = matches.length - 1; // wrap around
1355
+ vimState.statusMessage = `${idx + 1}/${matches.length}`;
1356
+ view.dispatch(moveCursor(state, matches[idx]));
1357
+ centerCursorInEditor(view, matches[idx]);
1358
+ }
1359
+ else {
1360
+ vimState.statusMessage = 'pattern not found';
1361
+ view.dispatch(state.tr);
1362
+ }
1363
+ }
1364
+ clearPendingState(vimState);
1365
+ return true;
1366
+ }
1367
+ case '*': {
1368
+ const word = wordUnderCursor(state, pos);
1369
+ if (word) {
1370
+ vimState.searchTerm = word;
1371
+ vimState.searchWholeWord = true;
1372
+ vimState.searchHighlightsVisible = true;
1373
+ const matches = findAllMatches(state, word, true);
1374
+ if (matches.length > 0) {
1375
+ let idx = matches.findIndex((m) => m > pos);
1376
+ if (idx === -1)
1377
+ idx = 0; // wrap around
1378
+ vimState.statusMessage = `${idx + 1}/${matches.length}`;
1379
+ view.dispatch(moveCursor(state, matches[idx]));
1380
+ centerCursorInEditor(view, matches[idx]);
1381
+ }
1382
+ }
1383
+ clearPendingState(vimState);
1384
+ return true;
1385
+ }
1386
+ // Dot repeat
1387
+ case '.': {
1388
+ if (vimState.lastAction) {
1389
+ replayLastAction(view, vimState, commands);
1390
+ }
1391
+ clearPendingState(vimState);
1392
+ return true;
1393
+ }
1394
+ }
1395
+ // Consume all remaining keys in normal mode to prevent them from inserting text
1396
+ if (key.length === 1) {
1397
+ return true;
1398
+ }
1399
+ return false;
1400
+ }