cliedit 0.2.0 → 0.3.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/dist/editor.d.ts CHANGED
@@ -52,6 +52,10 @@ export declare class CliEditor {
52
52
  y: number;
53
53
  x: number;
54
54
  }[];
55
+ searchResultMap: Map<number, Array<{
56
+ start: number;
57
+ end: number;
58
+ }>>;
55
59
  searchResultIndex: number;
56
60
  history: HistoryManager;
57
61
  swapManager: SwapManager;
package/dist/editor.js CHANGED
@@ -41,6 +41,8 @@ export class CliEditor {
41
41
  this.replaceQuery = null; // null = Find mode, string = Replace mode
42
42
  this.goToLineQuery = ''; // For Go to Line prompt
43
43
  this.searchResults = [];
44
+ // Map<lineNumber, Array<{ start, end }>> for fast rendering lookup
45
+ this.searchResultMap = new Map();
44
46
  this.searchResultIndex = -1;
45
47
  this.isCleanedUp = false;
46
48
  this.resolvePromise = null;
@@ -78,7 +80,8 @@ export class CliEditor {
78
80
  process.stdout.removeAllListeners('resize');
79
81
  // 2. (FIX GHOST TUI) Write exit sequence and use callback to ensure it's written
80
82
  // before Node.js fully releases the TTY.
81
- process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.MOVE_CURSOR_TOP_LEFT + ANSI.SHOW_CURSOR + ANSI.EXIT_ALTERNATE_SCREEN, () => {
83
+ // Disable mouse tracking (1000 and 1006)
84
+ process.stdout.write(ANSI.CLEAR_SCREEN + ANSI.MOVE_CURSOR_TOP_LEFT + ANSI.SHOW_CURSOR + ANSI.EXIT_ALTERNATE_SCREEN + '\x1b[?1000l' + '\x1b[?1006l', () => {
82
85
  // 3. Disable TTY raw mode and pause stdin after screen is cleared
83
86
  if (this.inputStream.setRawMode) {
84
87
  this.inputStream.setRawMode(false);
@@ -109,8 +112,8 @@ export class CliEditor {
109
112
  }
110
113
  this.updateScreenSize();
111
114
  this.recalculateVisualRows();
112
- // Enter alternate screen and hide cursor
113
- process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN);
115
+ // Enter alternate screen and hide cursor + Enable SGR Mouse (1006) and Button Event (1000)
116
+ process.stdout.write(ANSI.ENTER_ALTERNATE_SCREEN + ANSI.HIDE_CURSOR + ANSI.CLEAR_SCREEN + '\x1b[?1000h' + '\x1b[?1006h');
114
117
  if (this.inputStream.setRawMode) {
115
118
  this.inputStream.setRawMode(true);
116
119
  }
@@ -85,6 +85,11 @@ function handleKeypressEvent(ch, key) {
85
85
  keyName = 'ALT_LEFT';
86
86
  else if (key.meta && key.name === 'right')
87
87
  keyName = 'ALT_RIGHT';
88
+ // Handle Mouse Scroll events explicitly
89
+ else if (key.name === 'scrollup')
90
+ keyName = 'SCROLL_UP';
91
+ else if (key.name === 'scrolldown')
92
+ keyName = 'SCROLL_DOWN';
88
93
  else
89
94
  keyName = key.sequence;
90
95
  }
@@ -118,7 +123,55 @@ function handleKeypressEvent(ch, key) {
118
123
  return;
119
124
  }
120
125
  // 5. Xử lý tất cả các phím lệnh/chỉnh sửa khác
121
- edited = this.handleEditKeys(keyName || ch);
126
+ if (keyName === 'SCROLL_UP') {
127
+ const scrollAmount = 3;
128
+ this.rowOffset = Math.max(0, this.rowOffset - scrollAmount);
129
+ // Adjust cursor if it falls out of view (below the viewport)
130
+ // Actually if we scroll UP, the viewport moves UP. The cursor might be BELOW the viewport.
131
+ // Wait, scroll UP means viewing lines ABOVE. Viewport index decreases.
132
+ // Cursor (if previously in view) might now be >= rowOffset + screenRows.
133
+ // We need to ensure cursor is within [rowOffset, rowOffset + screenRows - 1]
134
+ // But verify after setting rowOffset.
135
+ const currentVisualRow = this.findCurrentVisualRowIndex();
136
+ const bottomEdge = this.rowOffset + this.screenRows - 1;
137
+ if (currentVisualRow > bottomEdge) {
138
+ const targetRow = this.visualRows[bottomEdge];
139
+ this.cursorY = targetRow.logicalY;
140
+ this.cursorX = targetRow.logicalXStart;
141
+ }
142
+ else if (currentVisualRow < this.rowOffset) {
143
+ // Should not happen when scrolling up (moving viewport up), unless cursor was already above?
144
+ // If we scroll up, rowOffset decreases. Current row stays same.
145
+ // So current row > new rowOffset.
146
+ // It might be > bottomEdge.
147
+ }
148
+ // However, to be safe against 'scroll' method resetting it:
149
+ // The 'scroll' method checks:
150
+ // if (currentVisualRow < this.rowOffset) -> this.rowOffset = currentVisualRow
151
+ // if (currentVisualRow >= this.rowOffset + this.screenRows) -> this.rowOffset = ...
152
+ // So we MUST move cursor inside the new viewport.
153
+ if (currentVisualRow > bottomEdge) {
154
+ const targetRow = this.visualRows[bottomEdge];
155
+ this.cursorY = targetRow.logicalY;
156
+ this.cursorX = targetRow.logicalXStart;
157
+ }
158
+ }
159
+ else if (keyName === 'SCROLL_DOWN') {
160
+ const scrollAmount = 3;
161
+ const maxOffset = Math.max(0, this.visualRows.length - this.screenRows);
162
+ this.rowOffset = Math.min(maxOffset, this.rowOffset + scrollAmount);
163
+ // Scroll DOWN means viewport index increases.
164
+ // Cursor might be ABOVE the new viewport (currentVisualRow < rowOffset).
165
+ const currentVisualRow = this.findCurrentVisualRowIndex();
166
+ if (currentVisualRow < this.rowOffset) {
167
+ const targetRow = this.visualRows[this.rowOffset];
168
+ this.cursorY = targetRow.logicalY;
169
+ this.cursorX = targetRow.logicalXStart;
170
+ }
171
+ }
172
+ else {
173
+ edited = this.handleEditKeys(keyName || ch);
174
+ }
122
175
  }
123
176
  // 6. Cập nhật Trạng thái và Render
124
177
  if (edited) {
@@ -130,6 +183,7 @@ function handleKeypressEvent(ch, key) {
130
183
  }
131
184
  }
132
185
  function handleAltArrows(keyName) {
186
+ this.clearSearchResults(); // Clear highlights on smart navigation
133
187
  if (keyName === 'ALT_LEFT')
134
188
  this.moveCursorByWord('left');
135
189
  else if (keyName === 'ALT_RIGHT')
@@ -147,6 +201,7 @@ function handleEditKeys(key) {
147
201
  ].includes(key);
148
202
  if (isNavigation) {
149
203
  this.cancelSelection();
204
+ this.clearSearchResults(); // Clear highlights on navigation
150
205
  if (this.isMessageCustom) {
151
206
  this.setStatusMessage(this.DEFAULT_STATUS, 0);
152
207
  }
@@ -189,9 +244,11 @@ function handleEditKeys(key) {
189
244
  return false;
190
245
  // --- Editing ---
191
246
  case KEYS.ENTER:
247
+ this.clearSearchResults();
192
248
  this.insertNewLine();
193
249
  return true;
194
250
  case KEYS.BACKSPACE:
251
+ this.clearSearchResults();
195
252
  // Handle auto-pair deletion
196
253
  const line = this.lines[this.cursorY] || '';
197
254
  const charBefore = line[this.cursorX - 1];
@@ -212,12 +269,14 @@ function handleEditKeys(key) {
212
269
  }
213
270
  return true;
214
271
  case KEYS.DELETE:
272
+ this.clearSearchResults();
215
273
  if (this.selectionAnchor)
216
274
  this.deleteSelectedText();
217
275
  else
218
276
  this.deleteForward();
219
277
  return true;
220
278
  case KEYS.TAB:
279
+ this.clearSearchResults();
221
280
  this.insertSoftTab();
222
281
  return true;
223
282
  // --- Search & History ---
@@ -273,6 +332,7 @@ function handleEditKeys(key) {
273
332
  // Xử lý Ký tự in được
274
333
  default:
275
334
  if (key.length === 1 && key >= ' ' && key <= '~') {
335
+ this.clearSearchResults();
276
336
  this.handleCharacterKey(key);
277
337
  return true;
278
338
  }
@@ -41,6 +41,11 @@ function render() {
41
41
  const displayX = cursorVisualX + this.gutterWidth;
42
42
  const displayY = this.screenStartRow + (currentVisualRowIndex - this.rowOffset);
43
43
  const selectionRange = this.getNormalizedSelection();
44
+ // Scrollbar calculations
45
+ const totalLines = this.visualRows.length;
46
+ const showScrollbar = totalLines > this.screenRows;
47
+ const thumbHeight = showScrollbar ? Math.max(1, Math.floor((this.screenRows / totalLines) * this.screenRows)) : 0;
48
+ const thumbStart = showScrollbar ? Math.floor((this.rowOffset / totalLines) * this.screenRows) : 0;
44
49
  // Draw visual rows
45
50
  for (let y = 0; y < this.screenRows; y++) {
46
51
  const visualRowIndex = y + this.rowOffset;
@@ -66,7 +71,19 @@ function render() {
66
71
  const isCursorPosition = (visualRowIndex === currentVisualRowIndex && i === cursorVisualX);
67
72
  const isSelected = selectionRange && this.isPositionInSelection(logicalY, logicalX, selectionRange);
68
73
  // Highlight search result under cursor
69
- const isSearchResult = (this.searchResultIndex !== -1 &&
74
+ // Check if this character is part of ANY search result
75
+ let isGlobalSearchResult = false;
76
+ if (this.searchResultMap.has(logicalY)) {
77
+ const matches = this.searchResultMap.get(logicalY);
78
+ for (const match of matches) {
79
+ if (logicalX >= match.start && logicalX < match.end) {
80
+ isGlobalSearchResult = true;
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ // Check if this character is part of the CURRENTLY SELECTED search result
86
+ const isCurrentSearchResult = (this.searchResultIndex !== -1 &&
70
87
  this.searchResults[this.searchResultIndex]?.y === logicalY &&
71
88
  logicalX >= this.searchResults[this.searchResultIndex]?.x &&
72
89
  logicalX < (this.searchResults[this.searchResultIndex]?.x + this.searchQuery.length));
@@ -77,8 +94,12 @@ function render() {
77
94
  // Cursor is a single inverted character if not already covered by selection
78
95
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
79
96
  }
80
- else if (isSearchResult) {
81
- // Highlight search result
97
+ else if (isCurrentSearchResult) {
98
+ // Selected Match: Invert + Underline (if supported) or just Invert
99
+ buffer += ANSI.INVERT_COLORS + '\x1b[4m' + char + ANSI.RESET_COLORS;
100
+ }
101
+ else if (isGlobalSearchResult) {
102
+ // Global Match: Invert only
82
103
  buffer += ANSI.INVERT_COLORS + char + ANSI.RESET_COLORS;
83
104
  }
84
105
  else {
@@ -93,6 +114,13 @@ function render() {
93
114
  }
94
115
  buffer += `${ANSI.CLEAR_LINE}`;
95
116
  }
117
+ // Draw Scrollbar (Phase 2)
118
+ if (showScrollbar) {
119
+ const isThumb = y >= thumbStart && y < thumbStart + thumbHeight;
120
+ const scrollChar = isThumb ? '┃' : '│';
121
+ // Move to last column and draw
122
+ buffer += `\x1b[${this.screenStartRow + y};${this.screenCols}H${ANSI.RESET_COLORS}${scrollChar}`;
123
+ }
96
124
  }
97
125
  // Draw status bar
98
126
  buffer += `\x1b[${this.screenRows + this.screenStartRow};1H`;
@@ -33,6 +33,10 @@ declare function jumpToResult(this: CliEditor, result: {
33
33
  y: number;
34
34
  x: number;
35
35
  }): void;
36
+ /**
37
+ * Clears the current search results and highlights.
38
+ */
39
+ declare function clearSearchResults(this: CliEditor): void;
36
40
  export declare const searchMethods: {
37
41
  enterFindMode: typeof enterFindMode;
38
42
  enterReplaceMode: typeof enterReplaceMode;
@@ -41,5 +45,6 @@ export declare const searchMethods: {
41
45
  replaceCurrentAndFindNext: typeof replaceCurrentAndFindNext;
42
46
  replaceAll: typeof replaceAll;
43
47
  jumpToResult: typeof jumpToResult;
48
+ clearSearchResults: typeof clearSearchResults;
44
49
  };
45
50
  export {};
@@ -29,13 +29,20 @@ function enterReplaceMode() {
29
29
  */
30
30
  function executeSearch() {
31
31
  this.searchResults = [];
32
+ this.searchResultMap.clear();
32
33
  if (this.searchQuery === '')
33
34
  return;
35
+ const queryLen = this.searchQuery.length;
34
36
  for (let y = 0; y < this.lines.length; y++) {
35
37
  const line = this.lines[y];
36
38
  let index = -1;
39
+ const lineMatches = [];
37
40
  while ((index = line.indexOf(this.searchQuery, index + 1)) !== -1) {
38
41
  this.searchResults.push({ y, x: index });
42
+ lineMatches.push({ start: index, end: index + queryLen });
43
+ }
44
+ if (lineMatches.length > 0) {
45
+ this.searchResultMap.set(y, lineMatches);
39
46
  }
40
47
  }
41
48
  this.searchResultIndex = -1;
@@ -158,6 +165,14 @@ function jumpToResult(result) {
158
165
  // Calculate new scroll offset to center the result visually
159
166
  this.rowOffset = Math.max(0, visualRowIndex - Math.floor(this.screenRows / 2));
160
167
  }
168
+ /**
169
+ * Clears the current search results and highlights.
170
+ */
171
+ function clearSearchResults() {
172
+ this.searchResults = [];
173
+ this.searchResultMap.clear();
174
+ this.searchResultIndex = -1;
175
+ }
161
176
  export const searchMethods = {
162
177
  enterFindMode,
163
178
  enterReplaceMode,
@@ -166,4 +181,5 @@ export const searchMethods = {
166
181
  replaceCurrentAndFindNext,
167
182
  replaceAll,
168
183
  jumpToResult,
184
+ clearSearchResults,
169
185
  };
@@ -18,6 +18,7 @@ if (!listenerCount) {
18
18
  */
19
19
  const metaKeyCodeRe = /^(?:\x1b)([a-zA-Z0-9])$/;
20
20
  const functionKeyCodeRe = /^(?:\x1b+)(O|N|\[|\[\[)(?:(\d+)(?:;(\d+))?([~^$])|(?:1;)?(\d+)?([a-zA-Z]))/;
21
+ const mouseSgrRe = /^\x1b\[<(\d+);(\d+);(\d+)([mM])/;
21
22
  /**
22
23
  * Hàm chính, chấp nhận một Readable Stream và làm cho nó
23
24
  * phát ra sự kiện "keypress".
@@ -421,7 +422,32 @@ function emitKey(stream, s) {
421
422
  }
422
423
  return;
423
424
  }
424
- // XXX: code phân tích "mouse" đã bị XÓA theo yêu cầu.
425
+ // Mouse handling (SGR 1006)
426
+ if ((parts = mouseSgrRe.exec(s))) {
427
+ // SGR Mode: \x1b[< b; x; y M/m
428
+ // b: button code
429
+ // x, y: coordinates (1-based)
430
+ // M/m: Press/Release
431
+ const b = parseInt(parts[1], 10);
432
+ const x = parseInt(parts[2], 10);
433
+ const y = parseInt(parts[3], 10);
434
+ const type = parts[4]; // M=press, m=release
435
+ key.name = 'mouse';
436
+ key.ctrl = false;
437
+ key.meta = false;
438
+ key.shift = false;
439
+ // Check for Scroll (Button 64 = Up, 65 = Down)
440
+ if (b === 64) {
441
+ key.name = 'scrollup';
442
+ key.code = 'scrollup';
443
+ }
444
+ else if (b === 65) {
445
+ key.name = 'scrolldown';
446
+ key.code = 'scrolldown';
447
+ }
448
+ // We can handle click here if needed (b=0 left, b=1 middle, b=2 right)
449
+ // but for now only scroll is requested.
450
+ }
425
451
  // Không phát ra key nếu không tìm thấy tên
426
452
  if (key.name === undefined) {
427
453
  return; // key = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cliedit",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A lightweight, raw-mode terminal editor utility for Node.js CLI applications, with line wrapping and undo/redo support.",
5
5
  "repository": "https://github.com/CodeTease/cliedit",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "build": "tsc",
23
23
  "prepublishOnly": "npm run build",
24
- "demo": "npm run build && cross-env NODE_ENV=development node dist/demo.js",
24
+ "demo": "npm run build && cross-env NODE_ENV=development tsx src/demo.ts",
25
25
  "test": "echo \"Error: no test specified\" && exit 1"
26
26
  },
27
27
  "keywords": [
@@ -35,10 +35,9 @@
35
35
  "author": "CodeTease",
36
36
  "license": "MIT",
37
37
  "devDependencies": {
38
- "@types/node": "^20.12.12",
39
- "cross-env": "^7.0.3",
40
- "typescript": "^5.4.5"
41
- },
42
- "dependencies": {
38
+ "@types/node": "^22",
39
+ "cross-env": "^7",
40
+ "tsx": "^4",
41
+ "typescript": "^5"
43
42
  }
44
- }
43
+ }