Skip to content

Commit ea17bf3

Browse files
authored
Vim enhancements (#353)
* Added register and implement line-wise command execution * Add count-aware h/l and soft-wrap-aware j/k motions * Implement Vim word/WORD motions (w/b/e and W/B/E) - Add w, b, e motions using Vim “word” semantics (punctuation-delimited) - Add W, B, E motions using Vim “WORD” semantics (non-punctuation-delimited) - Respect counts (e.g., 3w), direction, and end-of-line/whitespace edge cases - Match Vim behavior for cursor placement and motion boundaries * Implemented vim line motion 0 / ^ / $: line start (column 0), first non-blank, end of line 0 has some issues for now will be fixed later on * Refactor linewise command handling * Fixed the cursor issues with vim keyboard * Supports multi-key motions and refactor parsing * Added the ge command * Added file motion * Added view port motion * Improvemnts (wip) * Implement paste actions (p, P) This commit introduces the `p` (paste after) and `P` (paste before) actions. These actions paste content from the Vim clipboard into the editor at the cursor position. The actions correctly handle both character-wise and linewise pastes, adjusting the cursor position appropriately after the paste operation. * Fix operator-only command parsing (wip) * Implement indent and outdent operators This commit adds the indent (>) and outdent (<) operators to the Vim emulator. These operators allow users to increase or decrease the indentation of selected lines. The indentation level is determined by the `tabSize` setting. * Add toggle case action (~) * Implement character replacement (r) command This commit implements the character replacement command (r) in Vim mode. It allows users to replace a single character with another, and also supports the use of Enter and Tab as replacement characters.
1 parent 1030dff commit ea17bf3

23 files changed

+1803
-363
lines changed

src/hooks/use-vim-keyboard.ts

Lines changed: 179 additions & 165 deletions
Large diffs are not rendered by default.

src/stores/editor-state-store.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import { create } from "zustand";
33
import { subscribeWithSelector } from "zustand/middleware";
44
import { EDITOR_CONSTANTS } from "@/constants/editor-constants";
55
import type { Position, Range } from "@/types/editor-types";
6+
import { getLineHeight } from "@/utils/editor-position";
67
import { createSelectors } from "@/utils/zustand-selectors";
78
import { useBufferStore } from "./buffer-store";
9+
import { useEditorSettingsStore } from "./editor-settings-store";
810

911
// Position Cache Manager
1012
class PositionCacheManager {
@@ -48,6 +50,31 @@ class PositionCacheManager {
4850

4951
const positionCache = new PositionCacheManager();
5052

53+
const ensureCursorVisible = (position: Position) => {
54+
if (typeof window === "undefined") return;
55+
56+
const viewport = document.querySelector(".editor-viewport") as HTMLDivElement | null;
57+
if (!viewport) return;
58+
59+
const fontSize = useEditorSettingsStore.getState().fontSize;
60+
const lineHeight = getLineHeight(fontSize);
61+
const targetTop = position.line * lineHeight;
62+
const targetBottom = targetTop + lineHeight;
63+
const currentScrollTop = viewport.scrollTop;
64+
const viewportHeight = viewport.clientHeight || 0;
65+
66+
if (targetTop < currentScrollTop) {
67+
viewport.scrollTop = targetTop;
68+
} else if (targetBottom > currentScrollTop + viewportHeight) {
69+
viewport.scrollTop = Math.max(0, targetBottom - viewportHeight);
70+
}
71+
72+
const textarea = document.querySelector(".editor-textarea") as HTMLTextAreaElement | null;
73+
if (textarea && textarea.scrollTop !== viewport.scrollTop) {
74+
textarea.scrollTop = viewport.scrollTop;
75+
}
76+
};
77+
5178
// State Interface
5279
interface EditorState {
5380
// Cursor state
@@ -126,6 +153,7 @@ export const useEditorStateStore = createSelectors(
126153
positionCache.set(activeBufferId, position);
127154
}
128155
set({ cursorPosition: position });
156+
ensureCursorVisible(position);
129157
},
130158
setSelection: (selection) => set({ selection }),
131159
setDesiredColumn: (column) => set({ desiredColumn: column }),

src/stores/vim-store.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ interface VimState {
1919
end: { line: number; column: number } | null;
2020
};
2121
visualMode: "char" | "line" | null; // Track visual mode type
22+
register: {
23+
text: string;
24+
isLineWise: boolean;
25+
};
26+
lastOperation: {
27+
type: "command" | "action" | null;
28+
keys: string[];
29+
count?: number;
30+
} | null; // For repeat (.) functionality
2231
}
2332

2433
const defaultVimState: VimState = {
@@ -34,6 +43,11 @@ const defaultVimState: VimState = {
3443
end: null,
3544
},
3645
visualMode: null,
46+
register: {
47+
text: "",
48+
isLineWise: false,
49+
},
50+
lastOperation: null,
3751
};
3852

3953
const useVimStoreBase = create(
@@ -122,6 +136,13 @@ const useVimStoreBase = create(
122136
});
123137
},
124138

139+
setRegister: (text: string, isLineWise: boolean) => {
140+
set((state) => {
141+
state.register.text = text;
142+
state.register.isLineWise = isLineWise;
143+
});
144+
},
145+
125146
clearLastKey: () => {
126147
set((state) => {
127148
state.lastKey = null;
@@ -178,6 +199,23 @@ const useVimStoreBase = create(
178199
return "NORMAL";
179200
}
180201
},
202+
203+
// Last operation management for repeat functionality
204+
setLastOperation: (operation: VimState["lastOperation"]) => {
205+
set((state) => {
206+
state.lastOperation = operation;
207+
});
208+
},
209+
210+
getLastOperation: (): VimState["lastOperation"] => {
211+
return get().lastOperation;
212+
},
213+
214+
clearLastOperation: () => {
215+
set((state) => {
216+
state.lastOperation = null;
217+
});
218+
},
181219
},
182220
})),
183221
),

src/stores/vim/actions/index.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* Central registry for all vim actions
3+
*/
4+
5+
import type { Action } from "../core/types";
6+
import { pasteAction, pasteBeforeAction } from "./paste-actions";
7+
import { repeatAction } from "./repeat-action";
8+
import { replaceAction } from "./replace-action";
9+
import { toggleCaseAction } from "./toggle-case-action";
10+
11+
/**
12+
* Registry of all available actions
13+
*/
14+
export const actionRegistry: Record<string, Action> = {
15+
p: pasteAction,
16+
P: pasteBeforeAction,
17+
"~": toggleCaseAction,
18+
".": repeatAction,
19+
r: replaceAction,
20+
};
21+
22+
/**
23+
* Get an action by key
24+
*/
25+
export const getAction = (key: string): Action | undefined => {
26+
return actionRegistry[key];
27+
};
28+
29+
/**
30+
* Check if a key is a registered action
31+
*/
32+
export const isAction = (key: string): boolean => {
33+
return key in actionRegistry;
34+
};
35+
36+
/**
37+
* Get all action keys
38+
*/
39+
export const getActionKeys = (): string[] => {
40+
return Object.keys(actionRegistry);
41+
};
42+
43+
// Re-export actions
44+
export { pasteAction, pasteBeforeAction } from "./paste-actions";
45+
export { repeatAction } from "./repeat-action";
46+
export { createReplaceAction, replaceAction } from "./replace-action";
47+
export { toggleCaseAction } from "./toggle-case-action";
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Paste actions (p, P)
3+
*/
4+
5+
import { calculateOffsetFromPosition } from "@/utils/editor-position";
6+
import type { Action, EditorContext } from "../core/types";
7+
import { getVimClipboard } from "../operators/yank-operator";
8+
9+
/**
10+
* Paste after cursor (p)
11+
*/
12+
export const pasteAction: Action = {
13+
name: "paste",
14+
repeatable: true,
15+
entersInsertMode: false,
16+
17+
execute: (context: EditorContext): void => {
18+
const clipboard = getVimClipboard();
19+
if (!clipboard.content) return;
20+
21+
const { content, lines, updateContent, setCursorPosition, cursor } = context;
22+
23+
if (clipboard.linewise) {
24+
// Paste as new line below current line
25+
const newLines = [...lines];
26+
const pastedLines = clipboard.content.replace(/\n$/, "").split("\n");
27+
28+
// Insert after current line
29+
newLines.splice(cursor.line + 1, 0, ...pastedLines);
30+
const newContent = newLines.join("\n");
31+
32+
// Move cursor to beginning of first pasted line
33+
const newLine = cursor.line + 1;
34+
const newOffset = calculateOffsetFromPosition(newLine, 0, newLines);
35+
36+
updateContent(newContent);
37+
setCursorPosition({
38+
line: newLine,
39+
column: 0,
40+
offset: newOffset,
41+
});
42+
} else {
43+
// Character-wise paste after cursor
44+
let pasteOffset = cursor.offset;
45+
46+
// If not at end of line, move one character right
47+
if (cursor.offset < content.length && content[cursor.offset] !== "\n") {
48+
pasteOffset = cursor.offset + 1;
49+
}
50+
51+
const newContent =
52+
content.slice(0, pasteOffset) + clipboard.content + content.slice(pasteOffset);
53+
54+
updateContent(newContent);
55+
56+
// Move cursor to end of pasted content - 1 (vim behavior)
57+
const newOffset = pasteOffset + clipboard.content.length - 1;
58+
const newLines = newContent.split("\n");
59+
let line = 0;
60+
let offset = 0;
61+
62+
// Find the line containing the new cursor offset
63+
for (let i = 0; i < newLines.length; i++) {
64+
if (offset + newLines[i].length >= newOffset) {
65+
line = i;
66+
break;
67+
}
68+
offset += newLines[i].length + 1; // +1 for newline
69+
}
70+
71+
const column = newOffset - offset;
72+
setCursorPosition({
73+
line,
74+
column: Math.max(0, column),
75+
offset: Math.max(0, newOffset),
76+
});
77+
}
78+
},
79+
};
80+
81+
/**
82+
* Paste before cursor (P)
83+
*/
84+
export const pasteBeforeAction: Action = {
85+
name: "paste-before",
86+
repeatable: true,
87+
entersInsertMode: false,
88+
89+
execute: (context: EditorContext): void => {
90+
const clipboard = getVimClipboard();
91+
if (!clipboard.content) return;
92+
93+
const { content, lines, updateContent, setCursorPosition, cursor } = context;
94+
95+
if (clipboard.linewise) {
96+
// Paste as new line above current line
97+
const newLines = [...lines];
98+
const pastedLines = clipboard.content.replace(/\n$/, "").split("\n");
99+
100+
// Insert before current line
101+
newLines.splice(cursor.line, 0, ...pastedLines);
102+
const newContent = newLines.join("\n");
103+
104+
// Move cursor to beginning of first pasted line
105+
const newLine = cursor.line;
106+
const newOffset = calculateOffsetFromPosition(newLine, 0, newLines);
107+
108+
updateContent(newContent);
109+
setCursorPosition({
110+
line: newLine,
111+
column: 0,
112+
offset: newOffset,
113+
});
114+
} else {
115+
// Character-wise paste at cursor
116+
const newContent =
117+
content.slice(0, cursor.offset) + clipboard.content + content.slice(cursor.offset);
118+
119+
updateContent(newContent);
120+
121+
// Move cursor to end of pasted content - 1 (vim behavior)
122+
const newOffset = cursor.offset + clipboard.content.length - 1;
123+
const newLines = newContent.split("\n");
124+
let line = 0;
125+
let offset = 0;
126+
127+
// Find the line containing the new cursor offset
128+
for (let i = 0; i < newLines.length; i++) {
129+
if (offset + newLines[i].length >= newOffset) {
130+
line = i;
131+
break;
132+
}
133+
offset += newLines[i].length + 1; // +1 for newline
134+
}
135+
136+
const column = newOffset - offset;
137+
setCursorPosition({
138+
line,
139+
column: Math.max(0, column),
140+
offset: Math.max(0, newOffset),
141+
});
142+
}
143+
},
144+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Repeat action (.)
3+
*/
4+
5+
import { useVimStore } from "../../vim-store";
6+
import { executeVimCommand } from "../core/command-executor";
7+
import type { Action } from "../core/types";
8+
9+
/**
10+
* Repeat action - repeats the last operation
11+
*/
12+
export const repeatAction: Action = {
13+
name: "repeat",
14+
repeatable: false, // The dot command itself is not repeatable
15+
16+
execute: (): void => {
17+
const lastOperation = useVimStore.getState().lastOperation;
18+
19+
if (!lastOperation || !lastOperation.keys || lastOperation.keys.length === 0) {
20+
// Nothing to repeat
21+
return;
22+
}
23+
24+
// Execute the last command
25+
const success = executeVimCommand(lastOperation.keys);
26+
27+
if (!success) {
28+
console.warn("Failed to repeat last operation:", lastOperation.keys);
29+
}
30+
},
31+
};

0 commit comments

Comments
 (0)