cliedit 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 +88 -0
- package/dist/constants.d.ts +47 -0
- package/dist/constants.js +52 -0
- package/dist/editor.clipboard.d.ts +35 -0
- package/dist/editor.clipboard.js +129 -0
- package/dist/editor.d.ts +78 -0
- package/dist/editor.editing.d.ts +39 -0
- package/dist/editor.editing.js +115 -0
- package/dist/editor.history.d.ts +33 -0
- package/dist/editor.history.js +67 -0
- package/dist/editor.io.d.ts +17 -0
- package/dist/editor.io.js +30 -0
- package/dist/editor.js +121 -0
- package/dist/editor.keys.d.ts +21 -0
- package/dist/editor.keys.js +311 -0
- package/dist/editor.navigation.d.ts +42 -0
- package/dist/editor.navigation.js +132 -0
- package/dist/editor.rendering.d.ts +27 -0
- package/dist/editor.rendering.js +152 -0
- package/dist/editor.search.d.ts +30 -0
- package/dist/editor.search.js +58 -0
- package/dist/editor.selection.d.ts +19 -0
- package/dist/editor.selection.js +118 -0
- package/dist/history.d.ts +28 -0
- package/dist/history.js +57 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +26 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.js +2 -0
- package/package.json +44 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// src/editor.navigation.ts
|
|
2
|
+
/**
|
|
3
|
+
* Methods related to cursor movement and viewport scrolling.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Finds the index of the visual row that currently contains the cursor.
|
|
7
|
+
*/
|
|
8
|
+
function findCurrentVisualRowIndex() {
|
|
9
|
+
const contentWidth = this.screenCols - this.gutterWidth;
|
|
10
|
+
if (contentWidth <= 0)
|
|
11
|
+
return 0;
|
|
12
|
+
// Find the visual row index corresponding to the logical cursor position (cursorY, cursorX)
|
|
13
|
+
for (let i = 0; i < this.visualRows.length; i++) {
|
|
14
|
+
const row = this.visualRows[i];
|
|
15
|
+
if (row.logicalY === this.cursorY) {
|
|
16
|
+
// Check if cursorX falls within this visual row's content chunk
|
|
17
|
+
if (this.cursorX >= row.logicalXStart &&
|
|
18
|
+
this.cursorX <= row.logicalXStart + row.content.length) {
|
|
19
|
+
// Edge case: If cursorX is exactly at the start of a wrapped line (and not start of logical line),
|
|
20
|
+
// treat it as the end of the previous visual row for consistent movement.
|
|
21
|
+
if (this.cursorX > 0 && this.cursorX === row.logicalXStart && i > 0) {
|
|
22
|
+
return i - 1;
|
|
23
|
+
}
|
|
24
|
+
return i;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Optimization: if we've passed the cursor's logical line, the row must be the last one processed.
|
|
28
|
+
if (row.logicalY > this.cursorY) {
|
|
29
|
+
return i - 1;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return Math.max(0, this.visualRows.length - 1);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Moves the cursor one position left or right (logically, wrapping lines).
|
|
36
|
+
*/
|
|
37
|
+
function moveCursorLogically(dx) {
|
|
38
|
+
if (dx === -1) {
|
|
39
|
+
if (this.cursorX > 0) {
|
|
40
|
+
this.cursorX--;
|
|
41
|
+
}
|
|
42
|
+
else if (this.cursorY > 0) {
|
|
43
|
+
this.cursorY--;
|
|
44
|
+
this.cursorX = this.lines[this.cursorY].length;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
else if (dx === 1) {
|
|
48
|
+
const lineLength = this.lines[this.cursorY].length;
|
|
49
|
+
if (this.cursorX < lineLength) {
|
|
50
|
+
this.cursorX++;
|
|
51
|
+
}
|
|
52
|
+
else if (this.cursorY < this.lines.length - 1) {
|
|
53
|
+
this.cursorY++;
|
|
54
|
+
this.cursorX = 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Moves the cursor up or down by visual rows (dy).
|
|
60
|
+
*/
|
|
61
|
+
function moveCursorVisually(dy) {
|
|
62
|
+
const currentVisualRow = this.findCurrentVisualRowIndex();
|
|
63
|
+
const targetVisualRow = Math.max(0, Math.min(currentVisualRow + dy, this.visualRows.length - 1));
|
|
64
|
+
if (currentVisualRow === targetVisualRow)
|
|
65
|
+
return;
|
|
66
|
+
const targetRow = this.visualRows[targetVisualRow];
|
|
67
|
+
// Calculate the cursor's visual column position relative to its visual row start
|
|
68
|
+
const currentVisualX = this.cursorX - (this.visualRows[currentVisualRow]?.logicalXStart || 0);
|
|
69
|
+
this.cursorY = targetRow.logicalY;
|
|
70
|
+
// Maintain the visual column position as closely as possible
|
|
71
|
+
this.cursorX = Math.min(targetRow.logicalXStart + currentVisualX, this.lines[this.cursorY].length);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Finds the start of the current visual line (Home key behavior).
|
|
75
|
+
*/
|
|
76
|
+
function findVisualRowStart() {
|
|
77
|
+
const visualRow = this.visualRows[this.findCurrentVisualRowIndex()];
|
|
78
|
+
return visualRow.logicalXStart;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Finds the end of the current visual line (End key behavior).
|
|
82
|
+
*/
|
|
83
|
+
function findVisualRowEnd() {
|
|
84
|
+
const visualRow = this.visualRows[this.findCurrentVisualRowIndex()];
|
|
85
|
+
const lineLength = this.lines[visualRow.logicalY].length;
|
|
86
|
+
const contentWidth = this.screenCols - this.gutterWidth;
|
|
87
|
+
// The visual end is the start of the visual row + the maximum content width
|
|
88
|
+
const visualEnd = visualRow.logicalXStart + contentWidth;
|
|
89
|
+
// The actual logical X should be the minimum of the line's end and the visual end
|
|
90
|
+
return Math.min(lineLength, visualEnd);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Clamps the cursor position to valid coordinates and ensures it stays within line bounds.
|
|
94
|
+
*/
|
|
95
|
+
function adjustCursorPosition() {
|
|
96
|
+
// Clamp Y
|
|
97
|
+
if (this.cursorY < 0)
|
|
98
|
+
this.cursorY = 0;
|
|
99
|
+
if (this.cursorY >= this.lines.length) {
|
|
100
|
+
this.cursorY = Math.max(0, this.lines.length - 1);
|
|
101
|
+
}
|
|
102
|
+
// Clamp X
|
|
103
|
+
const lineLength = this.lines[this.cursorY]?.length || 0;
|
|
104
|
+
if (this.cursorX < 0)
|
|
105
|
+
this.cursorX = 0;
|
|
106
|
+
if (this.cursorX > lineLength) {
|
|
107
|
+
this.cursorX = lineLength;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Scrolls the viewport to keep the cursor visible.
|
|
112
|
+
*/
|
|
113
|
+
function scroll() {
|
|
114
|
+
const currentVisualRow = this.findCurrentVisualRowIndex();
|
|
115
|
+
// Scroll up
|
|
116
|
+
if (currentVisualRow < this.rowOffset) {
|
|
117
|
+
this.rowOffset = currentVisualRow;
|
|
118
|
+
}
|
|
119
|
+
// Scroll down
|
|
120
|
+
if (currentVisualRow >= this.rowOffset + this.screenRows) {
|
|
121
|
+
this.rowOffset = currentVisualRow - this.screenRows + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
export const navigationMethods = {
|
|
125
|
+
findCurrentVisualRowIndex,
|
|
126
|
+
moveCursorLogically,
|
|
127
|
+
moveCursorVisually,
|
|
128
|
+
findVisualRowStart,
|
|
129
|
+
findVisualRowEnd,
|
|
130
|
+
adjustCursorPosition,
|
|
131
|
+
scroll,
|
|
132
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { CliEditor } from './editor.js';
|
|
2
|
+
/**
|
|
3
|
+
* Core methods for rendering the document content, status bar, and cursor.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Recalculates the entire visual layout of the document based on screen width (line wrapping).
|
|
7
|
+
*/
|
|
8
|
+
declare function recalculateVisualRows(this: CliEditor): void;
|
|
9
|
+
/**
|
|
10
|
+
* The main rendering loop.
|
|
11
|
+
*/
|
|
12
|
+
declare function render(this: CliEditor): void;
|
|
13
|
+
/**
|
|
14
|
+
* Sets the status message and handles the timeout for custom messages.
|
|
15
|
+
*/
|
|
16
|
+
declare function setStatusMessage(this: CliEditor, message: string, timeoutMs?: number): void;
|
|
17
|
+
/**
|
|
18
|
+
* Generates the status bar content (bottom two lines).
|
|
19
|
+
*/
|
|
20
|
+
declare function renderStatusBar(this: CliEditor): string;
|
|
21
|
+
export declare const renderingMethods: {
|
|
22
|
+
recalculateVisualRows: typeof recalculateVisualRows;
|
|
23
|
+
render: typeof render;
|
|
24
|
+
setStatusMessage: typeof setStatusMessage;
|
|
25
|
+
renderStatusBar: typeof renderStatusBar;
|
|
26
|
+
};
|
|
27
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// src/editor.rendering.ts
|
|
2
|
+
import { ANSI } from './constants.js';
|
|
3
|
+
/**
|
|
4
|
+
* Core methods for rendering the document content, status bar, and cursor.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* Recalculates the entire visual layout of the document based on screen width (line wrapping).
|
|
8
|
+
*/
|
|
9
|
+
function recalculateVisualRows() {
|
|
10
|
+
this.visualRows = [];
|
|
11
|
+
// Calculate content width excluding gutter
|
|
12
|
+
const contentWidth = Math.max(1, this.screenCols - this.gutterWidth);
|
|
13
|
+
for (let y = 0; y < this.lines.length; y++) {
|
|
14
|
+
const line = this.lines[y];
|
|
15
|
+
if (line.length === 0) {
|
|
16
|
+
// Handle empty line case
|
|
17
|
+
this.visualRows.push({ logicalY: y, logicalXStart: 0, content: '' });
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
let x = 0;
|
|
21
|
+
// Chunk the line content based on contentWidth
|
|
22
|
+
while (x < line.length) {
|
|
23
|
+
const chunk = line.substring(x, x + contentWidth);
|
|
24
|
+
this.visualRows.push({ logicalY: y, logicalXStart: x, content: chunk });
|
|
25
|
+
x += contentWidth;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The main rendering loop.
|
|
32
|
+
*/
|
|
33
|
+
function render() {
|
|
34
|
+
this.adjustCursorPosition();
|
|
35
|
+
this.scroll();
|
|
36
|
+
let buffer = ANSI.MOVE_CURSOR_TOP_LEFT;
|
|
37
|
+
const currentVisualRowIndex = this.findCurrentVisualRowIndex();
|
|
38
|
+
const cursorVisualRow = this.visualRows[currentVisualRowIndex];
|
|
39
|
+
const cursorVisualX = cursorVisualRow ? (this.cursorX - cursorVisualRow.logicalXStart) : 0;
|
|
40
|
+
// Determine where the physical cursor should be placed
|
|
41
|
+
const displayX = cursorVisualX + this.gutterWidth;
|
|
42
|
+
const displayY = this.screenStartRow + (currentVisualRowIndex - this.rowOffset);
|
|
43
|
+
const selectionRange = this.getNormalizedSelection();
|
|
44
|
+
// Draw visual rows
|
|
45
|
+
for (let y = 0; y < this.screenRows; y++) {
|
|
46
|
+
const visualRowIndex = y + this.rowOffset;
|
|
47
|
+
// Move to start of the row
|
|
48
|
+
buffer += `\x1b[${this.screenStartRow + y};1H`;
|
|
49
|
+
if (visualRowIndex >= this.visualRows.length) {
|
|
50
|
+
// Draw Tilde for lines past file end
|
|
51
|
+
buffer += `~ ${ANSI.CLEAR_LINE}`;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const row = this.visualRows[visualRowIndex];
|
|
55
|
+
// 1. Draw Gutter (Line Number)
|
|
56
|
+
const lineNumber = (row.logicalXStart === 0)
|
|
57
|
+
? `${row.logicalY + 1}`.padStart(this.gutterWidth - 2, ' ') + ' | '
|
|
58
|
+
: ' '.padStart(this.gutterWidth - 2, ' ') + ' | ';
|
|
59
|
+
buffer += lineNumber;
|
|
60
|
+
let lineContent = row.content;
|
|
61
|
+
// 2. Draw Content (Character by Character for selection/cursor)
|
|
62
|
+
for (let i = 0; i < lineContent.length; i++) {
|
|
63
|
+
const char = lineContent[i];
|
|
64
|
+
const logicalX = row.logicalXStart + i;
|
|
65
|
+
const logicalY = row.logicalY;
|
|
66
|
+
const isCursorPosition = (visualRowIndex === currentVisualRowIndex && i === cursorVisualX);
|
|
67
|
+
const isSelected = selectionRange && this.isPositionInSelection(logicalY, logicalX, selectionRange);
|
|
68
|
+
if (isSelected) {
|
|
69
|
+
buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
|
|
70
|
+
}
|
|
71
|
+
else if (isCursorPosition) {
|
|
72
|
+
// Cursor is a single inverted character if not already covered by selection
|
|
73
|
+
buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
buffer += char;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
// 3. Handle Cursor at the absolute end of the line (drawing an inverted space)
|
|
80
|
+
const isCursorAtEndOfVisualLine = (visualRowIndex === currentVisualRowIndex && cursorVisualX === lineContent.length);
|
|
81
|
+
if (isCursorAtEndOfVisualLine) {
|
|
82
|
+
// If the cursor is at the end of the line/chunk, draw the inverted space
|
|
83
|
+
buffer += ANSI.INVERT_COLORS + ' ' + ANSI.RESET_COLORS;
|
|
84
|
+
}
|
|
85
|
+
buffer += `${ANSI.CLEAR_LINE}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Draw status bar
|
|
89
|
+
buffer += `\x1b[${this.screenRows + this.screenStartRow};1H`;
|
|
90
|
+
buffer += this.renderStatusBar();
|
|
91
|
+
// Set physical cursor position (ensure cursor is visible on screen)
|
|
92
|
+
if (displayY >= this.screenStartRow && displayY < this.screenRows + this.screenStartRow) {
|
|
93
|
+
buffer += `\x1b[${displayY};${displayX + 1}H`;
|
|
94
|
+
}
|
|
95
|
+
process.stdout.write(buffer);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Sets the status message and handles the timeout for custom messages.
|
|
99
|
+
*/
|
|
100
|
+
function setStatusMessage(message, timeoutMs = 3000) {
|
|
101
|
+
this.statusMessage = message;
|
|
102
|
+
this.isMessageCustom = message !== this.DEFAULT_STATUS;
|
|
103
|
+
if (this.statusTimeout) {
|
|
104
|
+
clearTimeout(this.statusTimeout);
|
|
105
|
+
this.statusTimeout = null;
|
|
106
|
+
}
|
|
107
|
+
if (message !== this.DEFAULT_STATUS && timeoutMs > 0) {
|
|
108
|
+
this.statusTimeout = setTimeout(() => {
|
|
109
|
+
this.statusMessage = this.DEFAULT_STATUS;
|
|
110
|
+
this.isMessageCustom = false;
|
|
111
|
+
this.statusTimeout = null;
|
|
112
|
+
if (!this.isCleanedUp)
|
|
113
|
+
this.renderStatusBar();
|
|
114
|
+
}, timeoutMs);
|
|
115
|
+
}
|
|
116
|
+
if (!this.isCleanedUp)
|
|
117
|
+
this.renderStatusBar();
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Generates the status bar content (bottom two lines).
|
|
121
|
+
*/
|
|
122
|
+
function renderStatusBar() {
|
|
123
|
+
let status = '';
|
|
124
|
+
const contentWidth = this.screenCols;
|
|
125
|
+
// --- Line 1: Mode, File Status, Position ---
|
|
126
|
+
if (this.mode === 'search') {
|
|
127
|
+
status = `SEARCH: ${this.searchQuery}`;
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
const visualRowIndex = this.findCurrentVisualRowIndex();
|
|
131
|
+
const visualRow = this.visualRows[visualRowIndex];
|
|
132
|
+
const visualX = visualRow ? (this.cursorX - visualRow.logicalXStart) : 0;
|
|
133
|
+
const fileStatus = this.isDirty ? `* ${this.filepath}` : this.filepath;
|
|
134
|
+
const pos = `Ln ${this.cursorY + 1}, Col ${this.cursorX + 1} (View: ${visualRowIndex + 1},${visualX + 1})`;
|
|
135
|
+
const statusLeft = `[${fileStatus}]`.padEnd(Math.floor(contentWidth * 0.5));
|
|
136
|
+
const statusRight = pos.padStart(Math.floor(contentWidth * 0.5));
|
|
137
|
+
status = statusLeft + statusRight;
|
|
138
|
+
}
|
|
139
|
+
status = status.padEnd(contentWidth);
|
|
140
|
+
let buffer = `${ANSI.INVERT_COLORS}${status}${ANSI.RESET_COLORS}`;
|
|
141
|
+
// --- Line 2: Message/Help line ---
|
|
142
|
+
buffer += `\x1b[${this.screenRows + this.screenStartRow + 1};1H`;
|
|
143
|
+
const message = this.statusMessage.padEnd(contentWidth);
|
|
144
|
+
buffer += `${message}${ANSI.CLEAR_LINE}`;
|
|
145
|
+
return buffer;
|
|
146
|
+
}
|
|
147
|
+
export const renderingMethods = {
|
|
148
|
+
recalculateVisualRows,
|
|
149
|
+
render,
|
|
150
|
+
setStatusMessage,
|
|
151
|
+
renderStatusBar,
|
|
152
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { CliEditor } from './editor.js';
|
|
2
|
+
/**
|
|
3
|
+
* Methods related to Find/Search functionality.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Enters search mode.
|
|
7
|
+
*/
|
|
8
|
+
declare function enterSearchMode(this: CliEditor): void;
|
|
9
|
+
/**
|
|
10
|
+
* Executes the search and populates results.
|
|
11
|
+
*/
|
|
12
|
+
declare function executeSearch(this: CliEditor): void;
|
|
13
|
+
/**
|
|
14
|
+
* Jumps to the next search result.
|
|
15
|
+
*/
|
|
16
|
+
declare function findNext(this: CliEditor): void;
|
|
17
|
+
/**
|
|
18
|
+
* Moves cursor and adjusts scroll offset to make the result visible.
|
|
19
|
+
*/
|
|
20
|
+
declare function jumpToResult(this: CliEditor, result: {
|
|
21
|
+
y: number;
|
|
22
|
+
x: number;
|
|
23
|
+
}): void;
|
|
24
|
+
export declare const searchMethods: {
|
|
25
|
+
enterSearchMode: typeof enterSearchMode;
|
|
26
|
+
executeSearch: typeof executeSearch;
|
|
27
|
+
findNext: typeof findNext;
|
|
28
|
+
jumpToResult: typeof jumpToResult;
|
|
29
|
+
};
|
|
30
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// src/editor.search.ts
|
|
2
|
+
/**
|
|
3
|
+
* Methods related to Find/Search functionality.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Enters search mode.
|
|
7
|
+
*/
|
|
8
|
+
function enterSearchMode() {
|
|
9
|
+
this.mode = 'search';
|
|
10
|
+
this.searchQuery = '';
|
|
11
|
+
this.searchResults = [];
|
|
12
|
+
this.searchResultIndex = -1;
|
|
13
|
+
this.setStatusMessage('Search (ESC/Ctrl+Q/C to cancel, ENTER to find): ');
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Executes the search and populates results.
|
|
17
|
+
*/
|
|
18
|
+
function executeSearch() {
|
|
19
|
+
this.searchResults = [];
|
|
20
|
+
if (this.searchQuery === '')
|
|
21
|
+
return;
|
|
22
|
+
for (let y = 0; y < this.lines.length; y++) {
|
|
23
|
+
const line = this.lines[y];
|
|
24
|
+
let index = -1;
|
|
25
|
+
while ((index = line.indexOf(this.searchQuery, index + 1)) !== -1) {
|
|
26
|
+
this.searchResults.push({ y, x: index });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
this.searchResultIndex = -1;
|
|
30
|
+
this.setStatusMessage(`Found ${this.searchResults.length} results for "${this.searchQuery}"`);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Jumps to the next search result.
|
|
34
|
+
*/
|
|
35
|
+
function findNext() {
|
|
36
|
+
if (this.searchResults.length === 0) {
|
|
37
|
+
this.setStatusMessage('No search results');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
this.searchResultIndex = (this.searchResultIndex + 1) % this.searchResults.length;
|
|
41
|
+
this.jumpToResult(this.searchResults[this.searchResultIndex]);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Moves cursor and adjusts scroll offset to make the result visible.
|
|
45
|
+
*/
|
|
46
|
+
function jumpToResult(result) {
|
|
47
|
+
this.cursorY = result.y;
|
|
48
|
+
this.cursorX = result.x;
|
|
49
|
+
const visualRowIndex = this.findCurrentVisualRowIndex();
|
|
50
|
+
// Calculate new scroll offset to center the result visually
|
|
51
|
+
this.rowOffset = Math.max(0, visualRowIndex - Math.floor(this.screenRows / 2));
|
|
52
|
+
}
|
|
53
|
+
export const searchMethods = {
|
|
54
|
+
enterSearchMode,
|
|
55
|
+
executeSearch,
|
|
56
|
+
findNext,
|
|
57
|
+
jumpToResult,
|
|
58
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type NormalizedRange = {
|
|
2
|
+
start: {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
end: {
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export type TSelectionMethods = {
|
|
12
|
+
startOrUpdateSelection: () => void;
|
|
13
|
+
cancelSelection: () => void;
|
|
14
|
+
getNormalizedSelection: () => NormalizedRange | null;
|
|
15
|
+
isPositionInSelection: (logicalY: number, logicalX: number, range: NormalizedRange) => boolean;
|
|
16
|
+
getSelectedText: () => string;
|
|
17
|
+
deleteSelectedText: () => void;
|
|
18
|
+
};
|
|
19
|
+
export declare const selectionMethods: TSelectionMethods;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// src/editor.selection.ts
|
|
2
|
+
/**
|
|
3
|
+
* Methods related to Text Selection management.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Starts or updates the selection anchor.
|
|
7
|
+
*/
|
|
8
|
+
function startOrUpdateSelection() {
|
|
9
|
+
if (!this.selectionAnchor) {
|
|
10
|
+
this.selectionAnchor = { x: this.cursorX, y: this.cursorY };
|
|
11
|
+
}
|
|
12
|
+
// If it exists, we just let the cursor move to update the selection end point.
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Cancels the current selection.
|
|
16
|
+
*/
|
|
17
|
+
function cancelSelection() {
|
|
18
|
+
this.selectionAnchor = null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Returns the selection range with 'start' always before 'end' (normalized).
|
|
22
|
+
*/
|
|
23
|
+
function getNormalizedSelection() {
|
|
24
|
+
if (!this.selectionAnchor)
|
|
25
|
+
return null;
|
|
26
|
+
const p1 = this.selectionAnchor;
|
|
27
|
+
const p2 = { x: this.cursorX, y: this.cursorY };
|
|
28
|
+
if (p1.y < p2.y || (p1.y === p2.y && p1.x < p2.x)) {
|
|
29
|
+
return { start: p1, end: p2 };
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
return { start: p2, end: p1 };
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Checks if a logical position (Y, X) is inside the normalized selection range.
|
|
37
|
+
*/
|
|
38
|
+
function isPositionInSelection(logicalY, logicalX, range) {
|
|
39
|
+
if (logicalY < range.start.y || logicalY > range.end.y)
|
|
40
|
+
return false;
|
|
41
|
+
// Check if the position is on the start line
|
|
42
|
+
if (logicalY === range.start.y && logicalX < range.start.x)
|
|
43
|
+
return false;
|
|
44
|
+
// Check if the position is on the end line
|
|
45
|
+
// The position is INCLUDED until it hits the end x-coordinate
|
|
46
|
+
if (logicalY === range.end.y && logicalX >= range.end.x)
|
|
47
|
+
return false;
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Extracts the selected text.
|
|
52
|
+
*/
|
|
53
|
+
function getSelectedText() {
|
|
54
|
+
const range = this.getNormalizedSelection();
|
|
55
|
+
if (!range)
|
|
56
|
+
return '';
|
|
57
|
+
let selectedLines = [];
|
|
58
|
+
for (let y = range.start.y; y <= range.end.y; y++) {
|
|
59
|
+
const line = this.lines[y] || '';
|
|
60
|
+
let startX = (y === range.start.y) ? range.start.x : 0;
|
|
61
|
+
let endX = (y === range.end.y) ? range.end.x : line.length;
|
|
62
|
+
// Ensure we don't try to select past the actual line length
|
|
63
|
+
endX = Math.min(endX, line.length);
|
|
64
|
+
if (startX < endX) {
|
|
65
|
+
selectedLines.push(line.substring(startX, endX));
|
|
66
|
+
}
|
|
67
|
+
else if (y === range.start.y && range.start.y === range.end.y && startX === endX) {
|
|
68
|
+
// Handle case where selection is zero-width, but on the same line (empty string)
|
|
69
|
+
}
|
|
70
|
+
else if (y < range.end.y) {
|
|
71
|
+
// If it's an empty line that is fully included in the selection
|
|
72
|
+
selectedLines.push('');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Join the extracted lines
|
|
76
|
+
return selectedLines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Deletes the currently selected text, adjusting the cursor position.
|
|
80
|
+
* Returns true if deletion occurred.
|
|
81
|
+
*/
|
|
82
|
+
function deleteSelectedText() {
|
|
83
|
+
const range = this.getNormalizedSelection();
|
|
84
|
+
if (!range)
|
|
85
|
+
return false;
|
|
86
|
+
const { start, end } = range;
|
|
87
|
+
// 1. Join the remaining parts:
|
|
88
|
+
// Part 1: Start line content before selection start
|
|
89
|
+
const startLineContent = this.lines[start.y].substring(0, start.x);
|
|
90
|
+
// Part 2: End line content after selection end
|
|
91
|
+
const endLineContent = this.lines[end.y].substring(end.x);
|
|
92
|
+
// 2. Set the content of the start line to the joined content
|
|
93
|
+
this.lines[start.y] = startLineContent + endLineContent;
|
|
94
|
+
// 3. Remove all lines between start.y and end.y (exclusive)
|
|
95
|
+
const linesToDeleteCount = end.y - start.y;
|
|
96
|
+
if (linesToDeleteCount > 0) {
|
|
97
|
+
this.lines.splice(start.y + 1, linesToDeleteCount);
|
|
98
|
+
}
|
|
99
|
+
// 4. Update cursor position (it moves to the start of the former selection)
|
|
100
|
+
this.cursorY = start.y;
|
|
101
|
+
this.cursorX = start.x;
|
|
102
|
+
// 5. Clear selection
|
|
103
|
+
this.cancelSelection();
|
|
104
|
+
this.setDirty();
|
|
105
|
+
// 6. Ensure we didn't accidentally make the document empty
|
|
106
|
+
if (this.lines.length === 0) {
|
|
107
|
+
this.lines = [''];
|
|
108
|
+
}
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
export const selectionMethods = {
|
|
112
|
+
startOrUpdateSelection,
|
|
113
|
+
cancelSelection,
|
|
114
|
+
getNormalizedSelection,
|
|
115
|
+
isPositionInSelection,
|
|
116
|
+
getSelectedText,
|
|
117
|
+
deleteSelectedText,
|
|
118
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { DocumentState } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Manages the undo/redo history stack for the editor.
|
|
4
|
+
*/
|
|
5
|
+
export declare class HistoryManager {
|
|
6
|
+
private undoHistory;
|
|
7
|
+
private redoHistory;
|
|
8
|
+
private readonly historyLimit;
|
|
9
|
+
constructor(historyLimit?: number);
|
|
10
|
+
/**
|
|
11
|
+
* Saves the current state to the undo history.
|
|
12
|
+
* This clears the redo history.
|
|
13
|
+
*/
|
|
14
|
+
saveState(state: DocumentState): void;
|
|
15
|
+
/**
|
|
16
|
+
* Performs an undo operation.
|
|
17
|
+
* @param currentState The state *before* undoing, to save to redo stack.
|
|
18
|
+
* @returns The state to restore (DocumentState) or null if no history.
|
|
19
|
+
*/
|
|
20
|
+
undo(currentState: DocumentState): DocumentState | null;
|
|
21
|
+
/**
|
|
22
|
+
* Performs a redo operation.
|
|
23
|
+
* @param currentState The state *before* redoing, to save to undo stack.
|
|
24
|
+
* @returns The state to restore (DocumentState) or null if no history.
|
|
25
|
+
*/
|
|
26
|
+
redo(currentState: DocumentState): DocumentState | null;
|
|
27
|
+
clear(): void;
|
|
28
|
+
}
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// src/history.ts
|
|
2
|
+
/**
|
|
3
|
+
* Manages the undo/redo history stack for the editor.
|
|
4
|
+
*/
|
|
5
|
+
export class HistoryManager {
|
|
6
|
+
constructor(historyLimit = 100) {
|
|
7
|
+
this.undoHistory = [];
|
|
8
|
+
this.redoHistory = [];
|
|
9
|
+
this.historyLimit = historyLimit;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Saves the current state to the undo history.
|
|
13
|
+
* This clears the redo history.
|
|
14
|
+
*/
|
|
15
|
+
saveState(state) {
|
|
16
|
+
// Clear redo history on new action
|
|
17
|
+
this.redoHistory = [];
|
|
18
|
+
// Add to undo history
|
|
19
|
+
this.undoHistory.push(state);
|
|
20
|
+
// Maintain history limit
|
|
21
|
+
if (this.undoHistory.length > this.historyLimit) {
|
|
22
|
+
this.undoHistory.shift();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Performs an undo operation.
|
|
27
|
+
* @param currentState The state *before* undoing, to save to redo stack.
|
|
28
|
+
* @returns The state to restore (DocumentState) or null if no history.
|
|
29
|
+
*/
|
|
30
|
+
undo(currentState) {
|
|
31
|
+
const previousState = this.undoHistory.pop();
|
|
32
|
+
if (!previousState) {
|
|
33
|
+
return null; // Nothing to undo
|
|
34
|
+
}
|
|
35
|
+
// Save current state to redo stack
|
|
36
|
+
this.redoHistory.push(currentState);
|
|
37
|
+
return previousState;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Performs a redo operation.
|
|
41
|
+
* @param currentState The state *before* redoing, to save to undo stack.
|
|
42
|
+
* @returns The state to restore (DocumentState) or null if no history.
|
|
43
|
+
*/
|
|
44
|
+
redo(currentState) {
|
|
45
|
+
const nextState = this.redoHistory.pop();
|
|
46
|
+
if (!nextState) {
|
|
47
|
+
return null; // Nothing to redo
|
|
48
|
+
}
|
|
49
|
+
// Save current (undone) state back to undo stack
|
|
50
|
+
this.undoHistory.push(currentState);
|
|
51
|
+
return nextState;
|
|
52
|
+
}
|
|
53
|
+
clear() {
|
|
54
|
+
this.undoHistory = [];
|
|
55
|
+
this.redoHistory = [];
|
|
56
|
+
}
|
|
57
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API function: Opens the editor.
|
|
3
|
+
* Reads the file and initializes CliEditor.
|
|
4
|
+
*/
|
|
5
|
+
export declare function openEditor(filepath: string): Promise<{
|
|
6
|
+
saved: boolean;
|
|
7
|
+
content: string;
|
|
8
|
+
}>;
|
|
9
|
+
export { CliEditor } from './editor.js';
|
|
10
|
+
export type { DocumentState, VisualRow, EditorMode } from './types.js';
|
|
11
|
+
export type { NormalizedRange } from './editor.selection.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { promises as fs } from 'fs';
|
|
3
|
+
import { CliEditor } from './editor.js';
|
|
4
|
+
/**
|
|
5
|
+
* Public API function: Opens the editor.
|
|
6
|
+
* Reads the file and initializes CliEditor.
|
|
7
|
+
*/
|
|
8
|
+
export async function openEditor(filepath) {
|
|
9
|
+
let initialContent = '';
|
|
10
|
+
try {
|
|
11
|
+
// 1. Read file
|
|
12
|
+
initialContent = await fs.readFile(filepath, 'utf-8');
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
// 2. If file does not exist (ENOENT), treat it as a new file
|
|
16
|
+
if (err.code !== 'ENOENT') {
|
|
17
|
+
throw err; // Throw error if not 'File not found'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
// 3. Initialize and run editor
|
|
21
|
+
const editor = new CliEditor(initialContent, filepath);
|
|
22
|
+
return editor.run();
|
|
23
|
+
}
|
|
24
|
+
// --- Public Exports ---
|
|
25
|
+
// Export the main class for advanced users
|
|
26
|
+
export { CliEditor } from './editor.js';
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Defines the necessary state for saving and restoring the document content
|
|
3
|
+
* and cursor position for the History Manager (Undo/Redo).
|
|
4
|
+
*/
|
|
5
|
+
export type DocumentState = {
|
|
6
|
+
lines: string[];
|
|
7
|
+
cursorX: number;
|
|
8
|
+
cursorY: number;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Defines a row as it is displayed on the terminal (after line wrapping).
|
|
12
|
+
*/
|
|
13
|
+
export interface VisualRow {
|
|
14
|
+
logicalY: number;
|
|
15
|
+
logicalXStart: number;
|
|
16
|
+
content: string;
|
|
17
|
+
}
|
|
18
|
+
export type EditorMode = 'edit' | 'search';
|
package/dist/types.js
ADDED