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.
- package/LICENSE +21 -0
- package/README.md +264 -0
- package/dist/extensions/vim/commands.d.ts +26 -0
- package/dist/extensions/vim/commands.js +231 -0
- package/dist/extensions/vim/commands.js.map +1 -0
- package/dist/extensions/vim/index.d.ts +3 -0
- package/dist/extensions/vim/index.js +2 -0
- package/dist/extensions/vim/index.js.map +1 -0
- package/dist/extensions/vim/keyHandler.d.ts +6 -0
- package/dist/extensions/vim/keyHandler.js +1400 -0
- package/dist/extensions/vim/keyHandler.js.map +1 -0
- package/dist/extensions/vim/motions.d.ts +80 -0
- package/dist/extensions/vim/motions.js +437 -0
- package/dist/extensions/vim/motions.js.map +1 -0
- package/dist/extensions/vim/operators.d.ts +36 -0
- package/dist/extensions/vim/operators.js +350 -0
- package/dist/extensions/vim/operators.js.map +1 -0
- package/dist/extensions/vim/state.d.ts +8 -0
- package/dist/extensions/vim/state.js +174 -0
- package/dist/extensions/vim/state.js.map +1 -0
- package/dist/extensions/vim/tiptap.d.ts +17 -0
- package/dist/extensions/vim/tiptap.js +49 -0
- package/dist/extensions/vim/types.d.ts +55 -0
- package/dist/extensions/vim/types.js +29 -0
- package/dist/extensions/vim/types.js.map +1 -0
- package/dist/extensions/vim/utils.d.ts +76 -0
- package/dist/extensions/vim/utils.js +224 -0
- package/dist/extensions/vim/utils.js.map +1 -0
- package/dist/extensions/vim/vim-mode.css +81 -0
- package/dist/extensions/vim/visual.d.ts +15 -0
- package/dist/extensions/vim/visual.js +58 -0
- package/dist/extensions/vim/visual.js.map +1 -0
- 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
|
+
}
|