ink-prompt 0.2.0 → 0.2.2
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/README.md +9 -2
- package/dist/components/MultilineInput/AtomicBlocks.d.ts +25 -0
- package/dist/components/MultilineInput/AtomicBlocks.js +60 -0
- package/dist/components/MultilineInput/TextBuffer.d.ts +5 -4
- package/dist/components/MultilineInput/TextBuffer.js +63 -86
- package/dist/components/MultilineInput/TextRenderer.d.ts +14 -2
- package/dist/components/MultilineInput/TextRenderer.js +99 -82
- package/dist/components/MultilineInput/__tests__/TextRenderer_images.test.js +4 -4
- package/dist/components/MultilineInput/useTextInput.js +68 -181
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,17 +36,24 @@ render(<App />);
|
|
|
36
36
|
|------|------|---------|-------------|
|
|
37
37
|
| `value` | `string` | | External control of the text value (controlled mode) |
|
|
38
38
|
| `onChange` | `(value: string) => void` | | Called when text content changes |
|
|
39
|
-
| `onSubmit` | `(value: string) => void` | | Called when Enter is pressed (without trailing `\`) |
|
|
39
|
+
| `onSubmit` | `(value: string, images?: ImageRef[]) => void` | | Called when Enter is pressed (without trailing `\`) |
|
|
40
40
|
| `placeholder` | `string` | | Placeholder text shown when empty and cursor is hidden |
|
|
41
41
|
| `showCursor` | `boolean` | `true` | Whether to display the cursor |
|
|
42
42
|
| `width` | `number` | terminal width | Width for word wrapping (auto-resizes with terminal) |
|
|
43
43
|
| `isActive` | `boolean` | `true` | Whether the input accepts keyboard events |
|
|
44
44
|
| `onCursorChange` | `(offset: number) => void` | | Called when cursor position changes |
|
|
45
45
|
| `cursorOverride` | `number` | | Force cursor to a specific offset |
|
|
46
|
-
| `onBoundaryArrow` | `(
|
|
46
|
+
| `onBoundaryArrow` | `(direction: 'up' \| 'down' \| 'left' \| 'right') => void` | | Called when arrow key reaches a boundary |
|
|
47
47
|
| `undoDebounceMs` | `number` | `200` | Milliseconds of inactivity to commit undo batch (`0` = disable) |
|
|
48
48
|
| `pasteThreshold` | `number` | | Max paste length before text is replaced by a placeholder |
|
|
49
49
|
| `formatPastePlaceholder` | `(id: number) => string` | | Custom placeholder display format |
|
|
50
|
+
| `enableImagePaste` | `boolean` | `false` | Enables image-aware Ctrl+V handling |
|
|
51
|
+
| `images` | `ImageRef[]` | | Controlled image state for pasted images |
|
|
52
|
+
| `onImagesChange` | `(images: ImageRef[]) => void` | | Called when images change |
|
|
53
|
+
| `onPasteError` | `(reason: PasteErrorReason) => void` | | Called when paste fails |
|
|
54
|
+
| `maxImageSizeBytes` | `number` | `10485760` | Maximum image size in bytes (default 10 MiB) |
|
|
55
|
+
| `maxImageCount` | `number` | `10` | Maximum number of pasted images |
|
|
56
|
+
| `acceptedMimeTypes` | `string[]` | | Restricts accepted image MIME types |
|
|
50
57
|
|
|
51
58
|
### Keyboard Controls
|
|
52
59
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { PlaceholderInfo } from './types.js';
|
|
2
|
+
export type AtomicBlock = {
|
|
3
|
+
kind: 'sentinel';
|
|
4
|
+
id: string;
|
|
5
|
+
displayNumber: number;
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
displayWidth: number;
|
|
9
|
+
displayText: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'placeholder';
|
|
12
|
+
id: number;
|
|
13
|
+
start: number;
|
|
14
|
+
end: number;
|
|
15
|
+
displayWidth: number;
|
|
16
|
+
displayText: string;
|
|
17
|
+
};
|
|
18
|
+
export type Placeholders = Map<number, PlaceholderInfo> | undefined;
|
|
19
|
+
export declare function findAtomicBlocks(line: string, placeholders?: Placeholders): AtomicBlock[];
|
|
20
|
+
/** Block that strictly contains offset (offset > start && offset < end) — cursor is in the interior. */
|
|
21
|
+
export declare function findAtomicBlockSpanning(line: string, offset: number, placeholders?: Placeholders): AtomicBlock | null;
|
|
22
|
+
/** Block whose end === offset (the block immediately to the left of the cursor). */
|
|
23
|
+
export declare function findAtomicBlockBefore(line: string, offset: number, placeholders?: Placeholders): AtomicBlock | null;
|
|
24
|
+
/** Block whose start === offset (the block immediately to the right of the cursor). */
|
|
25
|
+
export declare function findAtomicBlockAfter(line: string, offset: number, placeholders?: Placeholders): AtomicBlock | null;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseSentinels, getPlaceholderText } from './ImageSentinel.js';
|
|
2
|
+
import { MARKER_REGEX } from './Placeholder.js';
|
|
3
|
+
export function findAtomicBlocks(line, placeholders) {
|
|
4
|
+
const blocks = [];
|
|
5
|
+
for (const s of parseSentinels(line)) {
|
|
6
|
+
const displayText = getPlaceholderText(s.displayNumber);
|
|
7
|
+
blocks.push({
|
|
8
|
+
kind: 'sentinel',
|
|
9
|
+
id: s.id,
|
|
10
|
+
displayNumber: s.displayNumber,
|
|
11
|
+
start: s.start,
|
|
12
|
+
end: s.end,
|
|
13
|
+
displayWidth: displayText.length,
|
|
14
|
+
displayText,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
if (placeholders && placeholders.size > 0) {
|
|
18
|
+
MARKER_REGEX.lastIndex = 0;
|
|
19
|
+
let m;
|
|
20
|
+
while ((m = MARKER_REGEX.exec(line)) !== null) {
|
|
21
|
+
const id = Number(m[1]);
|
|
22
|
+
const info = placeholders.get(id);
|
|
23
|
+
const displayText = info ? info.displayText : '';
|
|
24
|
+
blocks.push({
|
|
25
|
+
kind: 'placeholder',
|
|
26
|
+
id,
|
|
27
|
+
start: m.index,
|
|
28
|
+
end: m.index + m[0].length,
|
|
29
|
+
displayWidth: displayText.length,
|
|
30
|
+
displayText,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
blocks.sort((a, b) => a.start - b.start);
|
|
35
|
+
return blocks;
|
|
36
|
+
}
|
|
37
|
+
/** Block that strictly contains offset (offset > start && offset < end) — cursor is in the interior. */
|
|
38
|
+
export function findAtomicBlockSpanning(line, offset, placeholders) {
|
|
39
|
+
for (const b of findAtomicBlocks(line, placeholders)) {
|
|
40
|
+
if (offset > b.start && offset < b.end)
|
|
41
|
+
return b;
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/** Block whose end === offset (the block immediately to the left of the cursor). */
|
|
46
|
+
export function findAtomicBlockBefore(line, offset, placeholders) {
|
|
47
|
+
for (const b of findAtomicBlocks(line, placeholders)) {
|
|
48
|
+
if (b.end === offset)
|
|
49
|
+
return b;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
/** Block whose start === offset (the block immediately to the right of the cursor). */
|
|
54
|
+
export function findAtomicBlockAfter(line, offset, placeholders) {
|
|
55
|
+
for (const b of findAtomicBlocks(line, placeholders)) {
|
|
56
|
+
if (b.start === offset)
|
|
57
|
+
return b;
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Buffer, Cursor, Direction } from './types.js';
|
|
2
|
+
import { type Placeholders } from './AtomicBlocks.js';
|
|
2
3
|
/**
|
|
3
4
|
* Create a new buffer from optional initial text
|
|
4
5
|
*/
|
|
@@ -14,14 +15,14 @@ export declare function insertText(buffer: Buffer, cursor: Cursor, text: string)
|
|
|
14
15
|
/**
|
|
15
16
|
* Delete character before cursor (backspace)
|
|
16
17
|
*/
|
|
17
|
-
export declare function deleteChar(buffer: Buffer, cursor: Cursor): {
|
|
18
|
+
export declare function deleteChar(buffer: Buffer, cursor: Cursor, placeholders?: Placeholders): {
|
|
18
19
|
buffer: Buffer;
|
|
19
20
|
cursor: Cursor;
|
|
20
21
|
};
|
|
21
22
|
/**
|
|
22
23
|
* Delete character after cursor (forward delete / Delete key)
|
|
23
24
|
*/
|
|
24
|
-
export declare function deleteCharForward(buffer: Buffer, cursor: Cursor): {
|
|
25
|
+
export declare function deleteCharForward(buffer: Buffer, cursor: Cursor, placeholders?: Placeholders): {
|
|
25
26
|
buffer: Buffer;
|
|
26
27
|
cursor: Cursor;
|
|
27
28
|
};
|
|
@@ -48,13 +49,13 @@ interface VisualRowInfo {
|
|
|
48
49
|
* Sentinel blocks are atomic: never split, and occupy visual width
|
|
49
50
|
* equal to their placeholder text length.
|
|
50
51
|
*/
|
|
51
|
-
export declare function getVisualRows(line: string, width: number): VisualRowInfo[];
|
|
52
|
+
export declare function getVisualRows(line: string, width: number, placeholders?: Placeholders): VisualRowInfo[];
|
|
52
53
|
/**
|
|
53
54
|
* Move cursor in specified direction with bounds checking.
|
|
54
55
|
* When width is provided, up/down movement is based on visual lines (accounting for wrapping).
|
|
55
56
|
* When width is not provided, up/down movement is based on buffer lines.
|
|
56
57
|
*/
|
|
57
|
-
export declare function moveCursor(buffer: Buffer, cursor: Cursor, direction: Direction, width?: number): Cursor;
|
|
58
|
+
export declare function moveCursor(buffer: Buffer, cursor: Cursor, direction: Direction, width?: number, placeholders?: Placeholders): Cursor;
|
|
58
59
|
/**
|
|
59
60
|
* Get the full text content from buffer (lines joined with newlines)
|
|
60
61
|
*/
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { parseSentinels, findSentinelAt, getPlaceholderVisualWidth } from './ImageSentinel.js';
|
|
1
|
+
import { findAtomicBlocks, findAtomicBlockBefore, findAtomicBlockAfter, } from './AtomicBlocks.js';
|
|
3
2
|
/**
|
|
4
3
|
* Create a new buffer from optional initial text
|
|
5
4
|
*/
|
|
@@ -44,7 +43,7 @@ export function insertText(buffer, cursor, text) {
|
|
|
44
43
|
/**
|
|
45
44
|
* Delete character before cursor (backspace)
|
|
46
45
|
*/
|
|
47
|
-
export function deleteChar(buffer, cursor) {
|
|
46
|
+
export function deleteChar(buffer, cursor, placeholders) {
|
|
48
47
|
const { line, column } = cursor;
|
|
49
48
|
// At the very start of the buffer - nothing to delete
|
|
50
49
|
if (line === 0 && column === 0) {
|
|
@@ -63,20 +62,17 @@ export function deleteChar(buffer, cursor) {
|
|
|
63
62
|
cursor: { line: line - 1, column: previousLine.length },
|
|
64
63
|
};
|
|
65
64
|
}
|
|
66
|
-
//
|
|
65
|
+
// Atomic-block deletion (sentinel or placeholder marker) \u2014 remove the whole block
|
|
67
66
|
const currentLine = buffer.lines[line];
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
newLines
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
cursor: { line, column: sentinel.start },
|
|
78
|
-
};
|
|
79
|
-
}
|
|
67
|
+
const block = findAtomicBlockBefore(currentLine, column, placeholders);
|
|
68
|
+
if (block) {
|
|
69
|
+
const newLine = currentLine.slice(0, block.start) + currentLine.slice(block.end);
|
|
70
|
+
const newLines = [...buffer.lines];
|
|
71
|
+
newLines[line] = newLine;
|
|
72
|
+
return {
|
|
73
|
+
buffer: { lines: newLines },
|
|
74
|
+
cursor: { line, column: block.start },
|
|
75
|
+
};
|
|
80
76
|
}
|
|
81
77
|
const newLine = currentLine.slice(0, column - 1) + currentLine.slice(column);
|
|
82
78
|
const newLines = [...buffer.lines];
|
|
@@ -89,7 +85,7 @@ export function deleteChar(buffer, cursor) {
|
|
|
89
85
|
/**
|
|
90
86
|
* Delete character after cursor (forward delete / Delete key)
|
|
91
87
|
*/
|
|
92
|
-
export function deleteCharForward(buffer, cursor) {
|
|
88
|
+
export function deleteCharForward(buffer, cursor, placeholders) {
|
|
93
89
|
const { line, column } = cursor;
|
|
94
90
|
const currentLine = buffer.lines[line];
|
|
95
91
|
const lineCount = buffer.lines.length;
|
|
@@ -109,19 +105,16 @@ export function deleteCharForward(buffer, cursor) {
|
|
|
109
105
|
cursor, // Cursor stays in place
|
|
110
106
|
};
|
|
111
107
|
}
|
|
112
|
-
//
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
newLines
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
cursor,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
108
|
+
// Atomic-block deletion forward (sentinel or placeholder marker) \u2014 remove the whole block
|
|
109
|
+
const block = findAtomicBlockAfter(currentLine, column, placeholders);
|
|
110
|
+
if (block) {
|
|
111
|
+
const newLine = currentLine.slice(0, block.start) + currentLine.slice(block.end);
|
|
112
|
+
const newLines = [...buffer.lines];
|
|
113
|
+
newLines[line] = newLine;
|
|
114
|
+
return {
|
|
115
|
+
buffer: { lines: newLines },
|
|
116
|
+
cursor,
|
|
117
|
+
};
|
|
125
118
|
}
|
|
126
119
|
// Delete character after cursor within the line
|
|
127
120
|
const newLine = currentLine.slice(0, column) + currentLine.slice(column + 1);
|
|
@@ -158,14 +151,15 @@ function getVisualWidth(char) {
|
|
|
158
151
|
* Sentinel blocks are atomic: never split, and occupy visual width
|
|
159
152
|
* equal to their placeholder text length.
|
|
160
153
|
*/
|
|
161
|
-
export function getVisualRows(line, width) {
|
|
154
|
+
export function getVisualRows(line, width, placeholders) {
|
|
162
155
|
const safeWidth = Math.max(1, width);
|
|
163
156
|
const rows = [];
|
|
164
157
|
if (line.length === 0) {
|
|
165
158
|
return [{ start: 0, length: 0 }];
|
|
166
159
|
}
|
|
167
|
-
|
|
168
|
-
|
|
160
|
+
const blocks = findAtomicBlocks(line, placeholders);
|
|
161
|
+
// Fast path: no atomic blocks
|
|
162
|
+
if (blocks.length === 0) {
|
|
169
163
|
let offset = 0;
|
|
170
164
|
let remaining = line;
|
|
171
165
|
while (remaining.length > 0) {
|
|
@@ -191,21 +185,19 @@ export function getVisualRows(line, width) {
|
|
|
191
185
|
}
|
|
192
186
|
return rows;
|
|
193
187
|
}
|
|
194
|
-
const sentinels = parseSentinels(line);
|
|
195
188
|
let charPos = 0;
|
|
196
189
|
let rowStart = 0;
|
|
197
190
|
let rowVisualWidth = 0;
|
|
198
191
|
let lastSpaceCharPos = -1;
|
|
192
|
+
let blockIdx = 0;
|
|
199
193
|
while (charPos < line.length) {
|
|
200
|
-
|
|
201
|
-
|
|
194
|
+
while (blockIdx < blocks.length && blocks[blockIdx].start < charPos)
|
|
195
|
+
blockIdx++;
|
|
196
|
+
const block = blockIdx < blocks.length && blocks[blockIdx].start === charPos
|
|
197
|
+
? blocks[blockIdx]
|
|
202
198
|
: undefined;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
effectiveSentinel = sentinels.find(s => s.start === charPos);
|
|
206
|
-
}
|
|
207
|
-
if (effectiveSentinel) {
|
|
208
|
-
const svw = getPlaceholderVisualWidth(effectiveSentinel.displayNumber);
|
|
199
|
+
if (block) {
|
|
200
|
+
const svw = block.displayWidth;
|
|
209
201
|
if (rowVisualWidth > 0 && rowVisualWidth + svw > safeWidth) {
|
|
210
202
|
if (lastSpaceCharPos >= rowStart) {
|
|
211
203
|
rows.push({ start: rowStart, length: lastSpaceCharPos - rowStart });
|
|
@@ -222,7 +214,7 @@ export function getVisualRows(line, width) {
|
|
|
222
214
|
}
|
|
223
215
|
}
|
|
224
216
|
rowVisualWidth += svw;
|
|
225
|
-
charPos =
|
|
217
|
+
charPos = block.end;
|
|
226
218
|
lastSpaceCharPos = -1;
|
|
227
219
|
continue;
|
|
228
220
|
}
|
|
@@ -262,8 +254,8 @@ export function getVisualRows(line, width) {
|
|
|
262
254
|
* and the column within that visual row.
|
|
263
255
|
* Uses word-aware wrapping.
|
|
264
256
|
*/
|
|
265
|
-
function getVisualPosition(bufferColumn, line, width) {
|
|
266
|
-
const rows = getVisualRows(line, width);
|
|
257
|
+
function getVisualPosition(bufferColumn, line, width, placeholders) {
|
|
258
|
+
const rows = getVisualRows(line, width, placeholders);
|
|
267
259
|
for (let i = 0; i < rows.length; i++) {
|
|
268
260
|
const row = rows[i];
|
|
269
261
|
const rowEnd = row.start + row.length;
|
|
@@ -290,15 +282,15 @@ function getVisualPosition(bufferColumn, line, width) {
|
|
|
290
282
|
* Calculate how many visual rows a buffer line takes.
|
|
291
283
|
* Uses word-aware wrapping.
|
|
292
284
|
*/
|
|
293
|
-
function getVisualRowCount(line, width) {
|
|
294
|
-
return getVisualRows(line, width).length;
|
|
285
|
+
function getVisualRowCount(line, width, placeholders) {
|
|
286
|
+
return getVisualRows(line, width, placeholders).length;
|
|
295
287
|
}
|
|
296
288
|
/**
|
|
297
289
|
* Convert visual position back to buffer column.
|
|
298
290
|
* Uses word-aware wrapping.
|
|
299
291
|
*/
|
|
300
|
-
function visualToBufferColumn(visualRow, visualCol, line, width) {
|
|
301
|
-
const rows = getVisualRows(line, width);
|
|
292
|
+
function visualToBufferColumn(visualRow, visualCol, line, width, placeholders) {
|
|
293
|
+
const rows = getVisualRows(line, width, placeholders);
|
|
302
294
|
if (visualRow >= rows.length) {
|
|
303
295
|
return line.length;
|
|
304
296
|
}
|
|
@@ -309,8 +301,8 @@ function visualToBufferColumn(visualRow, visualCol, line, width) {
|
|
|
309
301
|
* Get the length of a specific visual row within a buffer line.
|
|
310
302
|
* Uses word-aware wrapping.
|
|
311
303
|
*/
|
|
312
|
-
function getVisualRowLength(line, visualRow, width) {
|
|
313
|
-
const rows = getVisualRows(line, width);
|
|
304
|
+
function getVisualRowLength(line, visualRow, width, placeholders) {
|
|
305
|
+
const rows = getVisualRows(line, width, placeholders);
|
|
314
306
|
if (visualRow >= rows.length)
|
|
315
307
|
return 0;
|
|
316
308
|
return rows[visualRow].length;
|
|
@@ -320,21 +312,17 @@ function getVisualRowLength(line, visualRow, width) {
|
|
|
320
312
|
* When width is provided, up/down movement is based on visual lines (accounting for wrapping).
|
|
321
313
|
* When width is not provided, up/down movement is based on buffer lines.
|
|
322
314
|
*/
|
|
323
|
-
export function moveCursor(buffer, cursor, direction, width) {
|
|
315
|
+
export function moveCursor(buffer, cursor, direction, width, placeholders) {
|
|
324
316
|
const { line, column } = cursor;
|
|
325
317
|
const currentLine = buffer.lines[line];
|
|
326
318
|
const lineCount = buffer.lines.length;
|
|
327
319
|
switch (direction) {
|
|
328
320
|
case 'left': {
|
|
329
321
|
if (column > 0) {
|
|
330
|
-
// If position before cursor is
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
|
|
334
|
-
if (sentinel) {
|
|
335
|
-
return { line, column: sentinel.start };
|
|
336
|
-
}
|
|
337
|
-
}
|
|
322
|
+
// If position before cursor is the end of an atomic block, jump to its start
|
|
323
|
+
const block = findAtomicBlockBefore(currentLine, column, placeholders);
|
|
324
|
+
if (block)
|
|
325
|
+
return { line, column: block.start };
|
|
338
326
|
return { line, column: column - 1 };
|
|
339
327
|
}
|
|
340
328
|
// Wrap to end of previous line
|
|
@@ -345,14 +333,10 @@ export function moveCursor(buffer, cursor, direction, width) {
|
|
|
345
333
|
}
|
|
346
334
|
case 'right': {
|
|
347
335
|
if (column < currentLine.length) {
|
|
348
|
-
// If cursor is
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
351
|
-
|
|
352
|
-
if (sentinel) {
|
|
353
|
-
return { line, column: sentinel.end };
|
|
354
|
-
}
|
|
355
|
-
}
|
|
336
|
+
// If cursor is at the start of an atomic block, jump past its end
|
|
337
|
+
const block = findAtomicBlockAfter(currentLine, column, placeholders);
|
|
338
|
+
if (block)
|
|
339
|
+
return { line, column: block.end };
|
|
356
340
|
return { line, column: column + 1 };
|
|
357
341
|
}
|
|
358
342
|
// Wrap to start of next line
|
|
@@ -363,27 +347,23 @@ export function moveCursor(buffer, cursor, direction, width) {
|
|
|
363
347
|
}
|
|
364
348
|
case 'up':
|
|
365
349
|
if (width !== undefined) {
|
|
366
|
-
|
|
367
|
-
const { visualRow, visualCol } = getVisualPosition(column, currentLine, width);
|
|
350
|
+
const { visualRow, visualCol } = getVisualPosition(column, currentLine, width, placeholders);
|
|
368
351
|
if (visualRow > 0) {
|
|
369
|
-
// Move to previous visual row within the same buffer line
|
|
370
352
|
const targetVisualRow = visualRow - 1;
|
|
371
|
-
const targetVisualRowLength = getVisualRowLength(currentLine, targetVisualRow, width);
|
|
353
|
+
const targetVisualRowLength = getVisualRowLength(currentLine, targetVisualRow, width, placeholders);
|
|
372
354
|
const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
|
|
373
|
-
return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine, width) };
|
|
355
|
+
return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine, width, placeholders) };
|
|
374
356
|
}
|
|
375
|
-
// At first visual row of current line - move to last visual row of previous buffer line
|
|
376
357
|
if (line > 0) {
|
|
377
358
|
const prevLine = buffer.lines[line - 1];
|
|
378
|
-
const prevLineVisualRows = getVisualRowCount(prevLine, width);
|
|
359
|
+
const prevLineVisualRows = getVisualRowCount(prevLine, width, placeholders);
|
|
379
360
|
const targetVisualRow = prevLineVisualRows - 1;
|
|
380
|
-
const targetVisualRowLength = getVisualRowLength(prevLine, targetVisualRow, width);
|
|
361
|
+
const targetVisualRowLength = getVisualRowLength(prevLine, targetVisualRow, width, placeholders);
|
|
381
362
|
const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
|
|
382
|
-
return { line: line - 1, column: visualToBufferColumn(targetVisualRow, targetVisualCol, prevLine, width) };
|
|
363
|
+
return { line: line - 1, column: visualToBufferColumn(targetVisualRow, targetVisualCol, prevLine, width, placeholders) };
|
|
383
364
|
}
|
|
384
365
|
return cursor;
|
|
385
366
|
}
|
|
386
|
-
// Buffer-line movement (no width provided)
|
|
387
367
|
if (line > 0) {
|
|
388
368
|
const targetLine = buffer.lines[line - 1];
|
|
389
369
|
return { line: line - 1, column: Math.min(column, targetLine.length) };
|
|
@@ -391,20 +371,17 @@ export function moveCursor(buffer, cursor, direction, width) {
|
|
|
391
371
|
return cursor;
|
|
392
372
|
case 'down':
|
|
393
373
|
if (width !== undefined) {
|
|
394
|
-
|
|
395
|
-
const
|
|
396
|
-
const currentLineVisualRows = getVisualRowCount(currentLine, width);
|
|
374
|
+
const { visualRow, visualCol } = getVisualPosition(column, currentLine, width, placeholders);
|
|
375
|
+
const currentLineVisualRows = getVisualRowCount(currentLine, width, placeholders);
|
|
397
376
|
if (visualRow < currentLineVisualRows - 1) {
|
|
398
|
-
// Move to next visual row within the same buffer line
|
|
399
377
|
const targetVisualRow = visualRow + 1;
|
|
400
|
-
const targetVisualRowLength = getVisualRowLength(currentLine, targetVisualRow, width);
|
|
378
|
+
const targetVisualRowLength = getVisualRowLength(currentLine, targetVisualRow, width, placeholders);
|
|
401
379
|
const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
|
|
402
|
-
return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine, width) };
|
|
380
|
+
return { line, column: visualToBufferColumn(targetVisualRow, targetVisualCol, currentLine, width, placeholders) };
|
|
403
381
|
}
|
|
404
|
-
// At last visual row of current line - move to first visual row of next buffer line
|
|
405
382
|
if (line < lineCount - 1) {
|
|
406
383
|
const nextLine = buffer.lines[line + 1];
|
|
407
|
-
const targetVisualRowLength = getVisualRowLength(nextLine, 0, width);
|
|
384
|
+
const targetVisualRowLength = getVisualRowLength(nextLine, 0, width, placeholders);
|
|
408
385
|
const targetVisualCol = Math.min(visualCol, targetVisualRowLength);
|
|
409
386
|
return { line: line + 1, column: Math.min(targetVisualCol, nextLine.length) };
|
|
410
387
|
}
|
|
@@ -10,5 +10,17 @@ export interface TextRendererProps {
|
|
|
10
10
|
placeholderState?: PlaceholderState;
|
|
11
11
|
images?: Record<string, ImageRef>;
|
|
12
12
|
}
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
interface VisualSegment {
|
|
14
|
+
text: string;
|
|
15
|
+
dim: boolean;
|
|
16
|
+
}
|
|
17
|
+
interface VisualRow {
|
|
18
|
+
segments: VisualSegment[];
|
|
19
|
+
/** Total visual length (sum of segment text lengths). */
|
|
20
|
+
visualLength: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function wrapLines(buffer: Buffer, cursor: Cursor, width: number, placeholders?: PlaceholderState['placeholders']): WrapResult & {
|
|
23
|
+
rows: VisualRow[];
|
|
24
|
+
};
|
|
25
|
+
export declare function TextRenderer({ buffer, cursor, width: propWidth, showCursor, placeholderState, }: TextRendererProps): React.ReactElement;
|
|
26
|
+
export {};
|
|
@@ -2,11 +2,53 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { useTerminalWidth } from '../../hooks/useTerminalWidth.js';
|
|
4
4
|
import { getVisualRows } from './TextBuffer.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
import { findAtomicBlocks } from './AtomicBlocks.js';
|
|
6
|
+
/** Expand a [start, end) range of the raw line into styled segments. */
|
|
7
|
+
function expandRange(line, start, end, blocks) {
|
|
8
|
+
const segments = [];
|
|
9
|
+
let raw = start;
|
|
10
|
+
let plainStart = start;
|
|
11
|
+
while (raw < end) {
|
|
12
|
+
const block = blocks.find((b) => b.start === raw && b.end <= end);
|
|
13
|
+
if (block) {
|
|
14
|
+
if (raw > plainStart) {
|
|
15
|
+
segments.push({ text: line.slice(plainStart, raw), dim: false });
|
|
16
|
+
}
|
|
17
|
+
segments.push({ text: block.displayText, dim: block.kind === 'sentinel' });
|
|
18
|
+
raw = block.end;
|
|
19
|
+
plainStart = raw;
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
raw++;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (plainStart < end) {
|
|
26
|
+
segments.push({ text: line.slice(plainStart, end), dim: false });
|
|
27
|
+
}
|
|
28
|
+
return segments;
|
|
29
|
+
}
|
|
30
|
+
/** Visual column inside a row corresponding to a raw buffer column. */
|
|
31
|
+
function visualColumnForRawColumn(line, rowStart, column, blocks) {
|
|
32
|
+
let visualCol = 0;
|
|
33
|
+
let rawIndex = rowStart;
|
|
34
|
+
while (rawIndex < column) {
|
|
35
|
+
const block = blocks.find((b) => b.start === rawIndex);
|
|
36
|
+
if (block) {
|
|
37
|
+
if (column <= block.start)
|
|
38
|
+
return visualCol;
|
|
39
|
+
visualCol += block.displayWidth;
|
|
40
|
+
rawIndex = block.end;
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
visualCol += 1;
|
|
44
|
+
rawIndex++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return visualCol;
|
|
48
|
+
}
|
|
49
|
+
export function wrapLines(buffer, cursor, width, placeholders) {
|
|
9
50
|
const visualLines = [];
|
|
51
|
+
const rowsOut = [];
|
|
10
52
|
let cursorVisualRow = 0;
|
|
11
53
|
let cursorVisualCol = 0;
|
|
12
54
|
const safeWidth = Math.max(1, width);
|
|
@@ -14,10 +56,11 @@ export function wrapLines(buffer, cursor, width, images) {
|
|
|
14
56
|
for (let lineIndex = 0; lineIndex < buffer.lines.length; lineIndex++) {
|
|
15
57
|
const rawLine = buffer.lines[lineIndex];
|
|
16
58
|
const isCursorLine = lineIndex === cursor.line;
|
|
17
|
-
const
|
|
18
|
-
const rows = getVisualRows(rawLine, safeWidth);
|
|
59
|
+
const blocks = findAtomicBlocks(rawLine, placeholders);
|
|
60
|
+
const rows = getVisualRows(rawLine, safeWidth, placeholders);
|
|
19
61
|
if (rawLine.length === 0) {
|
|
20
62
|
visualLines.push('');
|
|
63
|
+
rowsOut.push({ segments: [], visualLength: 0 });
|
|
21
64
|
if (isCursorLine) {
|
|
22
65
|
cursorVisualRow = visualRowIndex;
|
|
23
66
|
cursorVisualCol = 0;
|
|
@@ -28,107 +71,81 @@ export function wrapLines(buffer, cursor, width, images) {
|
|
|
28
71
|
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
|
29
72
|
const row = rows[rowIndex];
|
|
30
73
|
const rowEnd = row.start + row.length;
|
|
31
|
-
const
|
|
32
|
-
|
|
74
|
+
const segments = expandRange(rawLine, row.start, rowEnd, blocks);
|
|
75
|
+
const visualLength = segments.reduce((sum, s) => sum + s.text.length, 0);
|
|
76
|
+
visualLines.push(segments.map((s) => s.text).join(''));
|
|
77
|
+
rowsOut.push({ segments, visualLength });
|
|
33
78
|
if (isCursorLine) {
|
|
34
79
|
if (cursor.column >= row.start && cursor.column < rowEnd) {
|
|
35
80
|
cursorVisualRow = visualRowIndex;
|
|
36
|
-
cursorVisualCol = visualColumnForRawColumn(rawLine, row.start, cursor.column,
|
|
81
|
+
cursorVisualCol = visualColumnForRawColumn(rawLine, row.start, cursor.column, blocks);
|
|
37
82
|
}
|
|
38
83
|
else if (cursor.column === rowEnd && rowIndex === rows.length - 1) {
|
|
39
84
|
cursorVisualRow = visualRowIndex;
|
|
40
|
-
cursorVisualCol =
|
|
85
|
+
cursorVisualCol = visualLength;
|
|
41
86
|
}
|
|
42
87
|
}
|
|
43
88
|
visualRowIndex++;
|
|
44
89
|
}
|
|
45
90
|
}
|
|
46
|
-
return { visualLines, cursorVisualRow, cursorVisualCol };
|
|
91
|
+
return { visualLines, cursorVisualRow, cursorVisualCol, rows: rowsOut };
|
|
47
92
|
}
|
|
48
|
-
function
|
|
49
|
-
|
|
50
|
-
let rawIndex = start;
|
|
51
|
-
while (rawIndex < end) {
|
|
52
|
-
const sentinel = sentinels.find(s => s.start === rawIndex);
|
|
53
|
-
if (sentinel && sentinel.end <= end) {
|
|
54
|
-
expanded += getPlaceholderText(sentinel.displayNumber);
|
|
55
|
-
rawIndex = sentinel.end;
|
|
56
|
-
}
|
|
57
|
-
else {
|
|
58
|
-
expanded += line[rawIndex];
|
|
59
|
-
rawIndex++;
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
return expanded;
|
|
93
|
+
function renderSegments(segments, keyPrefix) {
|
|
94
|
+
return segments.map((seg, i) => seg.dim ? (_jsx(Text, { dimColor: true, children: seg.text }, `${keyPrefix}-d-${i}`)) : (_jsx(Text, { children: seg.text }, `${keyPrefix}-t-${i}`)));
|
|
63
95
|
}
|
|
64
|
-
function
|
|
65
|
-
|
|
66
|
-
let
|
|
67
|
-
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
visualCol += getPlaceholderText(sentinel.displayNumber).length;
|
|
74
|
-
rawIndex = sentinel.end;
|
|
75
|
-
}
|
|
76
|
-
else {
|
|
77
|
-
visualCol += 1;
|
|
78
|
-
rawIndex++;
|
|
96
|
+
function sliceSegments(segments, start, end) {
|
|
97
|
+
const out = [];
|
|
98
|
+
let pos = 0;
|
|
99
|
+
for (const seg of segments) {
|
|
100
|
+
const segEnd = pos + seg.text.length;
|
|
101
|
+
if (segEnd <= start) {
|
|
102
|
+
pos = segEnd;
|
|
103
|
+
continue;
|
|
79
104
|
}
|
|
105
|
+
if (pos >= end)
|
|
106
|
+
break;
|
|
107
|
+
const sliceStart = Math.max(0, start - pos);
|
|
108
|
+
const sliceEnd = Math.min(seg.text.length, end - pos);
|
|
109
|
+
out.push({ text: seg.text.slice(sliceStart, sliceEnd), dim: seg.dim });
|
|
110
|
+
pos = segEnd;
|
|
80
111
|
}
|
|
81
|
-
return
|
|
112
|
+
return out;
|
|
82
113
|
}
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
while ((match = re.exec(line)) !== null) {
|
|
89
|
-
if (match.index > lastIndex) {
|
|
90
|
-
segments.push(_jsx(Text, { children: line.slice(lastIndex, match.index) }, `t-${lastIndex}`));
|
|
114
|
+
function charAtVisualCol(segments, col) {
|
|
115
|
+
let pos = 0;
|
|
116
|
+
for (const seg of segments) {
|
|
117
|
+
if (col < pos + seg.text.length) {
|
|
118
|
+
return { ch: seg.text[col - pos], dim: seg.dim };
|
|
91
119
|
}
|
|
92
|
-
|
|
93
|
-
lastIndex = match.index + match[0].length;
|
|
94
|
-
}
|
|
95
|
-
if (lastIndex < line.length) {
|
|
96
|
-
segments.push(_jsx(Text, { children: line.slice(lastIndex) }, `t-${lastIndex}`));
|
|
120
|
+
pos += seg.text.length;
|
|
97
121
|
}
|
|
98
|
-
return
|
|
122
|
+
return { ch: ' ', dim: false };
|
|
99
123
|
}
|
|
100
|
-
function
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
return _jsx(_Fragment, { children: renderLineSegments(line) });
|
|
105
|
-
}
|
|
106
|
-
if (!showCursor) {
|
|
107
|
-
if (line.length === 0)
|
|
124
|
+
function renderVisualRow(row, isCursorRow, cursorCol, showCursor) {
|
|
125
|
+
const { segments, visualLength } = row;
|
|
126
|
+
if (!isCursorRow || !showCursor) {
|
|
127
|
+
if (visualLength === 0)
|
|
108
128
|
return _jsx(Text, { children: " " });
|
|
109
|
-
return _jsx(_Fragment, { children:
|
|
129
|
+
return _jsx(_Fragment, { children: renderSegments(segments, 'r') });
|
|
110
130
|
}
|
|
111
|
-
if (
|
|
131
|
+
if (visualLength === 0) {
|
|
112
132
|
return _jsx(Text, { inverse: true, children: " " });
|
|
113
133
|
}
|
|
114
|
-
const before =
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
134
|
+
const before = sliceSegments(segments, 0, cursorCol);
|
|
135
|
+
const under = cursorCol < visualLength
|
|
136
|
+
? charAtVisualCol(segments, cursorCol)
|
|
137
|
+
: { ch: ' ', dim: false };
|
|
138
|
+
const after = cursorCol < visualLength
|
|
139
|
+
? sliceSegments(segments, cursorCol + 1, visualLength)
|
|
140
|
+
: [];
|
|
141
|
+
return (_jsxs(_Fragment, { children: [renderSegments(before, 'b'), _jsx(Text, { inverse: true, dimColor: under.dim, children: under.ch }), renderSegments(after, 'a')] }));
|
|
118
142
|
}
|
|
119
|
-
export function TextRenderer({ buffer, cursor, width: propWidth, showCursor = true, placeholderState,
|
|
143
|
+
export function TextRenderer({ buffer, cursor, width: propWidth, showCursor = true, placeholderState, }) {
|
|
120
144
|
const width = useTerminalWidth(propWidth);
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
? { lines: buffer.lines.map(line => getDisplayLine(line, placeholderState.placeholders)) }
|
|
125
|
-
: buffer;
|
|
126
|
-
const displayCursor = hasPlaceholders && cursor.line < buffer.lines.length
|
|
127
|
-
? { line: cursor.line, column: bufferColToDisplayCol(buffer.lines[cursor.line], cursor.column, placeholderState.placeholders) }
|
|
128
|
-
: cursor;
|
|
129
|
-
const { visualLines, cursorVisualRow, cursorVisualCol } = wrapLines(displayBuffer, displayCursor, width, images);
|
|
130
|
-
return (_jsx(Box, { flexDirection: "column", children: visualLines.map((line, index) => {
|
|
145
|
+
const placeholders = placeholderState?.placeholders;
|
|
146
|
+
const { rows, cursorVisualRow, cursorVisualCol } = wrapLines(buffer, cursor, width, placeholders);
|
|
147
|
+
return (_jsx(Box, { flexDirection: "column", children: rows.map((row, index) => {
|
|
131
148
|
const isCursorRow = index === cursorVisualRow;
|
|
132
|
-
return (_jsx(Box, { children:
|
|
149
|
+
return (_jsx(Box, { children: renderVisualRow(row, isCursorRow, cursorVisualCol, showCursor) }, index));
|
|
133
150
|
}) }));
|
|
134
151
|
}
|
|
@@ -14,7 +14,7 @@ describe('TextRenderer with images', () => {
|
|
|
14
14
|
it('renders normal text unchanged when no sentinels', () => {
|
|
15
15
|
const buffer = { lines: ['hello'] };
|
|
16
16
|
const cursor = { line: 0, column: 5 };
|
|
17
|
-
const result = wrapLines(buffer, cursor, 80
|
|
17
|
+
const result = wrapLines(buffer, cursor, 80);
|
|
18
18
|
expect(result.visualLines).toEqual(['hello']);
|
|
19
19
|
expect(result.cursorVisualRow).toBe(0);
|
|
20
20
|
expect(result.cursorVisualCol).toBe(5);
|
|
@@ -22,7 +22,7 @@ describe('TextRenderer with images', () => {
|
|
|
22
22
|
it('renders sentinel placeholder text in visual lines', () => {
|
|
23
23
|
const buffer = { lines: [sentinel1] };
|
|
24
24
|
const cursor = { line: 0, column: sentinel1.length };
|
|
25
|
-
const result = wrapLines(buffer, cursor, 80
|
|
25
|
+
const result = wrapLines(buffer, cursor, 80);
|
|
26
26
|
expect(result.visualLines).toEqual(['[Pasted Image #1]']);
|
|
27
27
|
expect(result.cursorVisualRow).toBe(0);
|
|
28
28
|
expect(result.cursorVisualCol).toBe(17);
|
|
@@ -30,13 +30,13 @@ describe('TextRenderer with images', () => {
|
|
|
30
30
|
it('renders text around sentinel placeholder', () => {
|
|
31
31
|
const buffer = { lines: [`hello ${sentinel1} world`] };
|
|
32
32
|
const cursor = { line: 0, column: 0 };
|
|
33
|
-
const result = wrapLines(buffer, cursor, 80
|
|
33
|
+
const result = wrapLines(buffer, cursor, 80);
|
|
34
34
|
expect(result.visualLines).toEqual(['hello [Pasted Image #1] world']);
|
|
35
35
|
});
|
|
36
36
|
it('does not split sentinel placeholder text while wrapping', () => {
|
|
37
37
|
const buffer = { lines: [`aa ${sentinel1}`] };
|
|
38
38
|
const cursor = { line: 0, column: `aa ${sentinel1}`.length };
|
|
39
|
-
const result = wrapLines(buffer, cursor, 10
|
|
39
|
+
const result = wrapLines(buffer, cursor, 10);
|
|
40
40
|
expect(result.visualLines).toEqual(['aa', '[Pasted Image #1]']);
|
|
41
41
|
expect(result.cursorVisualRow).toBe(1);
|
|
42
42
|
expect(result.cursorVisualCol).toBe('[Pasted Image #1]'.length);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
2
|
-
import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, getTextContent,
|
|
3
|
-
import { createPlaceholderState, addPlaceholder, removePlaceholder, getValue, getValueCursorOffset, getCursorFromValueOffset,
|
|
4
|
-
import { createSentinel, parseSentinels
|
|
2
|
+
import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, getTextContent, } from './TextBuffer.js';
|
|
3
|
+
import { createPlaceholderState, addPlaceholder, removePlaceholder, getValue, getValueCursorOffset, getCursorFromValueOffset, } from './Placeholder.js';
|
|
4
|
+
import { createSentinel, parseSentinels } from './ImageSentinel.js';
|
|
5
|
+
import { findAtomicBlockBefore, findAtomicBlockAfter } from './AtomicBlocks.js';
|
|
5
6
|
import { log } from '../../utils/logger.js';
|
|
6
7
|
const defaultFormatPlaceholder = (id) => `[Paste text #${id}]`;
|
|
7
8
|
export function useTextInput({ initialValue = '', width, historyLimit = 100, undoDebounceMs = 200, pasteThreshold, formatPastePlaceholder = defaultFormatPlaceholder, } = {}) {
|
|
@@ -80,6 +81,16 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
80
81
|
pendingInsertBatchRef.current.startState = undefined;
|
|
81
82
|
};
|
|
82
83
|
}, [clearPendingInsertTimer]);
|
|
84
|
+
/** Run a buffer-mutating edit with history bookkeeping (flush batch + push undo). */
|
|
85
|
+
const applyEdit = useCallback((edit) => {
|
|
86
|
+
flushPendingInsertBatch();
|
|
87
|
+
pushToHistory(buffer, cursor);
|
|
88
|
+
const result = edit();
|
|
89
|
+
if (result) {
|
|
90
|
+
setBuffer(result.buffer);
|
|
91
|
+
setCursor(result.cursor);
|
|
92
|
+
}
|
|
93
|
+
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
83
94
|
const insert = useCallback((char) => {
|
|
84
95
|
log(`[INSERT] char="${char.replace(/[\x00-\x1F\x7F-]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" len=${char.length} cursor={line:${cursor.line},col:${cursor.column}} linesBefore=${buffer.lines.length}`);
|
|
85
96
|
const normalized = char.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\x00/g, '');
|
|
@@ -110,159 +121,44 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
110
121
|
setBuffer(result.buffer);
|
|
111
122
|
setCursor(result.cursor);
|
|
112
123
|
}, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs, pasteThreshold, placeholderState, formatPastePlaceholder]);
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
pushToHistory(buffer, cursor);
|
|
116
|
-
const line = buffer.lines[cursor.line];
|
|
117
|
-
// Check if deleting a paste placeholder marker
|
|
118
|
-
const placeholder = findPlaceholderBefore(line, cursor.column);
|
|
119
|
-
if (placeholder) {
|
|
120
|
-
const newLine = line.slice(0, placeholder.start) + line.slice(placeholder.end);
|
|
121
|
-
const newLines = [...buffer.lines];
|
|
122
|
-
newLines[cursor.line] = newLine;
|
|
123
|
-
setBuffer({ lines: newLines });
|
|
124
|
-
setCursor({ line: cursor.line, column: placeholder.start });
|
|
125
|
-
setPlaceholderState(prev => removePlaceholder(prev, placeholder.id));
|
|
124
|
+
const cleanupBlockRegistry = useCallback((block) => {
|
|
125
|
+
if (!block)
|
|
126
126
|
return;
|
|
127
|
+
if (block.kind === 'placeholder') {
|
|
128
|
+
setPlaceholderState((prev) => removePlaceholder(prev, block.id));
|
|
127
129
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const newImages = { ...images };
|
|
133
|
-
delete newImages[sentinel.id];
|
|
134
|
-
setImages(newImages);
|
|
135
|
-
}
|
|
130
|
+
else if (block.kind === 'sentinel' && images[block.id]) {
|
|
131
|
+
const next = { ...images };
|
|
132
|
+
delete next[block.id];
|
|
133
|
+
setImages(next);
|
|
136
134
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
135
|
+
}, [images]);
|
|
136
|
+
const deleteChar = useCallback(() => {
|
|
137
|
+
applyEdit(() => {
|
|
138
|
+
const line = buffer.lines[cursor.line];
|
|
139
|
+
cleanupBlockRegistry(findAtomicBlockBefore(line, cursor.column, placeholderState.placeholders));
|
|
140
|
+
return bufferDeleteChar(buffer, cursor, placeholderState.placeholders);
|
|
141
|
+
});
|
|
142
|
+
}, [applyEdit, buffer, cursor, placeholderState, cleanupBlockRegistry]);
|
|
141
143
|
const deleteCharForward = useCallback(() => {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
const newLine = line.slice(0, placeholder.start) + line.slice(placeholder.end);
|
|
149
|
-
const newLines = [...buffer.lines];
|
|
150
|
-
newLines[cursor.line] = newLine;
|
|
151
|
-
setBuffer({ lines: newLines });
|
|
152
|
-
setPlaceholderState(prev => removePlaceholder(prev, placeholder.id));
|
|
153
|
-
return;
|
|
154
|
-
}
|
|
155
|
-
// Check if deleting a sentinel (image)
|
|
156
|
-
if (cursor.column < line.length && line[cursor.column] === '') {
|
|
157
|
-
const sentinel = findSentinelAt(line, cursor.column);
|
|
158
|
-
if (sentinel && images[sentinel.id]) {
|
|
159
|
-
const newImages = { ...images };
|
|
160
|
-
delete newImages[sentinel.id];
|
|
161
|
-
setImages(newImages);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
const result = bufferDeleteCharForward(buffer, cursor);
|
|
165
|
-
setBuffer(result.buffer);
|
|
166
|
-
setCursor(result.cursor);
|
|
167
|
-
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory, images, placeholderState]);
|
|
144
|
+
applyEdit(() => {
|
|
145
|
+
const line = buffer.lines[cursor.line];
|
|
146
|
+
cleanupBlockRegistry(findAtomicBlockAfter(line, cursor.column, placeholderState.placeholders));
|
|
147
|
+
return bufferDeleteCharForward(buffer, cursor, placeholderState.placeholders);
|
|
148
|
+
});
|
|
149
|
+
}, [applyEdit, buffer, cursor, placeholderState, cleanupBlockRegistry]);
|
|
168
150
|
const newLine = useCallback(() => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const result = bufferInsertNewLine(buffer, cursor);
|
|
172
|
-
setBuffer(result.buffer);
|
|
173
|
-
setCursor(result.cursor);
|
|
174
|
-
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
151
|
+
applyEdit(() => bufferInsertNewLine(buffer, cursor));
|
|
152
|
+
}, [applyEdit, buffer, cursor]);
|
|
175
153
|
const deleteAndNewLine = useCallback(() => {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
setCursor(afterNewLine.cursor);
|
|
182
|
-
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
154
|
+
applyEdit(() => {
|
|
155
|
+
const afterDelete = bufferDeleteChar(buffer, cursor, placeholderState.placeholders);
|
|
156
|
+
return bufferInsertNewLine(afterDelete.buffer, afterDelete.cursor);
|
|
157
|
+
});
|
|
158
|
+
}, [applyEdit, buffer, cursor, placeholderState]);
|
|
183
159
|
const moveCursor = useCallback((direction) => {
|
|
184
160
|
flushPendingInsertBatch();
|
|
185
|
-
|
|
186
|
-
// Handle placeholder markers: skip over them for left/right
|
|
187
|
-
if (placeholderState.placeholders.size > 0) {
|
|
188
|
-
if (direction === 'left' || direction === 'right') {
|
|
189
|
-
const marker = findPlaceholderAt(buffer.lines[newCursor.line], newCursor.column);
|
|
190
|
-
if (marker) {
|
|
191
|
-
newCursor = {
|
|
192
|
-
...newCursor,
|
|
193
|
-
column: direction === 'left' ? marker.start : marker.end,
|
|
194
|
-
};
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
// For up/down with visual-aware navigation, use display-space conversion
|
|
198
|
-
if ((direction === 'up' || direction === 'down') && width !== undefined) {
|
|
199
|
-
const { line, column } = newCursor;
|
|
200
|
-
const currentLine = buffer.lines[line];
|
|
201
|
-
const displayLine = getDisplayLine(currentLine, placeholderState.placeholders);
|
|
202
|
-
const displayCol = bufferColToDisplayCol(currentLine, column, placeholderState.placeholders);
|
|
203
|
-
const rows = getVisualRows(displayLine, width);
|
|
204
|
-
const getVisualPos = (col, str) => {
|
|
205
|
-
const r = getVisualRows(str, width);
|
|
206
|
-
for (let i = 0; i < r.length; i++) {
|
|
207
|
-
const rowEnd = r[i].start + r[i].length;
|
|
208
|
-
if (col >= r[i].start && col < rowEnd) {
|
|
209
|
-
return { visualRow: i, visualCol: col - r[i].start };
|
|
210
|
-
}
|
|
211
|
-
if (col === rowEnd && i === r.length - 1) {
|
|
212
|
-
return { visualRow: i, visualCol: r[i].length };
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
for (let i = 0; i < r.length; i++) {
|
|
216
|
-
if (col === r[i].start + r[i].length && i < r.length - 1) {
|
|
217
|
-
return { visualRow: i + 1, visualCol: 0 };
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
const lastRow = r[r.length - 1];
|
|
221
|
-
return { visualRow: r.length - 1, visualCol: lastRow.length };
|
|
222
|
-
};
|
|
223
|
-
const visualPos = getVisualPos(displayCol, displayLine);
|
|
224
|
-
const displayRowCount = rows.length;
|
|
225
|
-
if (direction === 'up') {
|
|
226
|
-
if (visualPos.visualRow > 0) {
|
|
227
|
-
const targetVisRow = visualPos.visualRow - 1;
|
|
228
|
-
const targetVisRowLen = targetVisRow < rows.length ? rows[targetVisRow].length : 0;
|
|
229
|
-
const targetVisCol = Math.min(visualPos.visualCol, targetVisRowLen);
|
|
230
|
-
const targetDispCol = Math.min(rows[targetVisRow].start + targetVisCol, displayLine.length);
|
|
231
|
-
const targetBufCol = displayColToBufferCol(currentLine, targetDispCol, placeholderState.placeholders);
|
|
232
|
-
newCursor = { line, column: targetBufCol };
|
|
233
|
-
}
|
|
234
|
-
else if (line > 0) {
|
|
235
|
-
const prevLine = buffer.lines[line - 1];
|
|
236
|
-
const prevDisplayLine = getDisplayLine(prevLine, placeholderState.placeholders);
|
|
237
|
-
const prevRows = getVisualRows(prevDisplayLine, width);
|
|
238
|
-
const targetVisRow = prevRows.length - 1;
|
|
239
|
-
const targetVisRowLen = targetVisRow >= 0 ? prevRows[targetVisRow].length : 0;
|
|
240
|
-
const targetVisCol = Math.min(visualPos.visualCol, targetVisRowLen);
|
|
241
|
-
const targetDispCol = targetVisRow >= 0 ? Math.min(prevRows[targetVisRow].start + targetVisCol, prevDisplayLine.length) : 0;
|
|
242
|
-
const targetBufCol = displayColToBufferCol(prevLine, targetDispCol, placeholderState.placeholders);
|
|
243
|
-
newCursor = { line: line - 1, column: targetBufCol };
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
else {
|
|
247
|
-
if (visualPos.visualRow < displayRowCount - 1) {
|
|
248
|
-
const targetVisRow = visualPos.visualRow + 1;
|
|
249
|
-
const targetVisRowLen = targetVisRow < rows.length ? rows[targetVisRow].length : 0;
|
|
250
|
-
const targetVisCol = Math.min(visualPos.visualCol, targetVisRowLen);
|
|
251
|
-
const targetDispCol = Math.min(rows[targetVisRow].start + targetVisCol, displayLine.length);
|
|
252
|
-
const targetBufCol = displayColToBufferCol(currentLine, targetDispCol, placeholderState.placeholders);
|
|
253
|
-
newCursor = { line, column: targetBufCol };
|
|
254
|
-
}
|
|
255
|
-
else if (line < buffer.lines.length - 1) {
|
|
256
|
-
const nextLine = buffer.lines[line + 1];
|
|
257
|
-
const nextDisplayLine = getDisplayLine(nextLine, placeholderState.placeholders);
|
|
258
|
-
const firstRowLen = nextDisplayLine.length > 0 ? (getVisualRows(nextDisplayLine, width)[0]?.length ?? 0) : 0;
|
|
259
|
-
const targetVisCol = Math.min(visualPos.visualCol, firstRowLen);
|
|
260
|
-
const targetBufCol = displayColToBufferCol(nextLine, targetVisCol, placeholderState.placeholders);
|
|
261
|
-
newCursor = { line: line + 1, column: targetBufCol };
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
161
|
+
const newCursor = bufferMoveCursor(buffer, cursor, direction, width, placeholderState.placeholders);
|
|
266
162
|
setCursor(newCursor);
|
|
267
163
|
}, [buffer, cursor, flushPendingInsertBatch, width, placeholderState]);
|
|
268
164
|
const undo = useCallback(() => {
|
|
@@ -304,42 +200,33 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
304
200
|
setRedoStack(newRedoStack);
|
|
305
201
|
}, [buffer, cursor, redoStack, placeholderState, images]);
|
|
306
202
|
const setText = useCallback((text) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
const sentinels = parseSentinels(fullText);
|
|
320
|
-
const usedIds = new Set(sentinels.map(s => s.id));
|
|
321
|
-
setImages((prev) => {
|
|
322
|
-
const next = {};
|
|
323
|
-
for (const [id, ref] of Object.entries(prev)) {
|
|
324
|
-
if (usedIds.has(id)) {
|
|
325
|
-
next[id] = ref;
|
|
203
|
+
applyEdit(() => {
|
|
204
|
+
setPlaceholderState(createPlaceholderState());
|
|
205
|
+
const newBuffer = createBuffer(text);
|
|
206
|
+
const lines = text.split('\n');
|
|
207
|
+
const newCursor = { line: lines.length - 1, column: lines[lines.length - 1].length };
|
|
208
|
+
// Clean up orphaned images
|
|
209
|
+
const usedIds = new Set(parseSentinels(getTextContent(newBuffer)).map((s) => s.id));
|
|
210
|
+
setImages((prev) => {
|
|
211
|
+
const next = {};
|
|
212
|
+
for (const [id, ref] of Object.entries(prev)) {
|
|
213
|
+
if (usedIds.has(id))
|
|
214
|
+
next[id] = ref;
|
|
326
215
|
}
|
|
327
|
-
|
|
328
|
-
|
|
216
|
+
return next;
|
|
217
|
+
});
|
|
218
|
+
return { buffer: newBuffer, cursor: newCursor };
|
|
329
219
|
});
|
|
330
|
-
}, [
|
|
220
|
+
}, [applyEdit]);
|
|
331
221
|
const insertImage = useCallback((imageRef) => {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
nextDisplayNumberRef.current = imageRef.displayNumber + 1;
|
|
341
|
-
}
|
|
342
|
-
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
222
|
+
applyEdit(() => {
|
|
223
|
+
setImages((prev) => ({ ...prev, [imageRef.id]: imageRef }));
|
|
224
|
+
if (imageRef.displayNumber >= nextDisplayNumberRef.current) {
|
|
225
|
+
nextDisplayNumberRef.current = imageRef.displayNumber + 1;
|
|
226
|
+
}
|
|
227
|
+
return bufferInsertText(buffer, cursor, createSentinel(imageRef.id, imageRef.displayNumber));
|
|
228
|
+
});
|
|
229
|
+
}, [applyEdit, buffer, cursor]);
|
|
343
230
|
const value = useMemo(() => getValue(buffer.lines, placeholderState.placeholders), [buffer.lines, placeholderState.placeholders]);
|
|
344
231
|
const cursorOffset = useMemo(() => getValueCursorOffset(buffer.lines, cursor, placeholderState.placeholders), [buffer.lines, cursor, placeholderState.placeholders]);
|
|
345
232
|
const imagesList = useMemo(() => Object.values(images), [images]);
|