ink-prompt 0.2.2 → 0.2.4
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 +2 -2
- package/dist/components/MultilineInput/AtomicBlocks.d.ts +10 -20
- package/dist/components/MultilineInput/AtomicBlocks.js +33 -42
- package/dist/components/MultilineInput/BlockMarker.d.ts +19 -0
- package/dist/components/MultilineInput/BlockMarker.js +83 -0
- package/dist/components/MultilineInput/BlockRegistry.d.ts +39 -0
- package/dist/components/MultilineInput/BlockRegistry.js +236 -0
- package/dist/components/MultilineInput/BlockTypes.d.ts +22 -0
- package/dist/components/MultilineInput/ImageTypes.d.ts +0 -2
- package/dist/components/MultilineInput/ImageTypes.js +1 -2
- package/dist/components/MultilineInput/ImageValidator.js +1 -1
- package/dist/components/MultilineInput/KeyHandler.d.ts +1 -1
- package/dist/components/MultilineInput/TextBuffer.d.ts +5 -31
- package/dist/components/MultilineInput/TextBuffer.js +91 -161
- package/dist/components/MultilineInput/TextRenderer.d.ts +6 -7
- package/dist/components/MultilineInput/TextRenderer.js +7 -7
- package/dist/components/MultilineInput/__tests__/BlockMarker.test.js +130 -0
- package/dist/components/MultilineInput/__tests__/BlockRegistry.test.js +225 -0
- package/dist/components/MultilineInput/__tests__/Placeholder.integration.test.js +44 -65
- package/dist/components/MultilineInput/__tests__/TextBuffer_images.test.js +10 -31
- package/dist/components/MultilineInput/__tests__/TextRenderer_images.test.js +27 -13
- package/dist/components/MultilineInput/__tests__/integration_images.test.js +2 -4
- package/dist/components/MultilineInput/__tests__/useTextInput_images.test.js +30 -29
- package/dist/components/MultilineInput/index.d.ts +6 -6
- package/dist/components/MultilineInput/index.js +56 -13
- package/dist/components/MultilineInput/types.d.ts +0 -20
- package/dist/components/MultilineInput/useTextInput.d.ts +4 -11
- package/dist/components/MultilineInput/useTextInput.js +79 -76
- package/package.json +1 -1
- package/dist/components/MultilineInput/Placeholder.d.ts +0 -30
- package/dist/components/MultilineInput/Placeholder.js +0 -200
- package/dist/components/MultilineInput/__tests__/Placeholder.test.js +0 -235
- package/dist/examples/examples/basic.js +0 -9
- package/dist/examples/src/components/MultilineInput/KeyHandler.d.ts +0 -15
- package/dist/examples/src/components/MultilineInput/KeyHandler.js +0 -97
- package/dist/examples/src/components/MultilineInput/TextBuffer.d.ts +0 -34
- package/dist/examples/src/components/MultilineInput/TextBuffer.js +0 -127
- package/dist/examples/src/components/MultilineInput/TextRenderer.d.ts +0 -24
- package/dist/examples/src/components/MultilineInput/TextRenderer.js +0 -72
- package/dist/examples/src/components/MultilineInput/__tests__/KeyHandler.test.js +0 -115
- package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/TextBuffer.test.js +0 -254
- package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/TextRenderer.test.js +0 -176
- package/dist/examples/src/components/MultilineInput/__tests__/integration.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/integration.test.js +0 -71
- package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.d.ts +0 -1
- package/dist/examples/src/components/MultilineInput/__tests__/useTextInput.test.js +0 -65
- package/dist/examples/src/components/MultilineInput/index.d.ts +0 -39
- package/dist/examples/src/components/MultilineInput/index.js +0 -82
- package/dist/examples/src/components/MultilineInput/types.d.ts +0 -55
- package/dist/examples/src/components/MultilineInput/types.js +0 -1
- package/dist/examples/src/components/MultilineInput/useTextInput.d.ts +0 -16
- package/dist/examples/src/components/MultilineInput/useTextInput.js +0 -82
- package/dist/examples/src/hello.test.d.ts +0 -1
- package/dist/examples/src/hello.test.js +0 -13
- package/dist/examples/src/index.d.ts +0 -2
- package/dist/examples/src/index.js +0 -2
- /package/dist/components/MultilineInput/{__tests__/Placeholder.test.d.ts → BlockTypes.js} +0 -0
- /package/dist/{examples/examples/basic.d.ts → components/MultilineInput/__tests__/BlockMarker.test.d.ts} +0 -0
- /package/dist/{examples/src/components/MultilineInput/__tests__/KeyHandler.test.d.ts → components/MultilineInput/__tests__/BlockRegistry.test.d.ts} +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createBlockState, createPasteBlockEntry, createImageBlockEntry, removeBlock, getBlock, getDisplayLine, getValue, bufferColToDisplayCol, displayColToBufferCol, getValueCursorOffset, getCursorFromValueOffset, } from '../BlockRegistry.js';
|
|
3
|
+
import { parseBlockMarkers } from '../BlockMarker.js';
|
|
4
|
+
function makeImageRef(overrides = {}) {
|
|
5
|
+
return {
|
|
6
|
+
id: 'img1',
|
|
7
|
+
data: '',
|
|
8
|
+
mimeType: 'image/png',
|
|
9
|
+
byteSize: 100,
|
|
10
|
+
displayNumber: 1,
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
describe('createBlockState', () => {
|
|
15
|
+
it('creates empty state with counters at 1', () => {
|
|
16
|
+
const state = createBlockState();
|
|
17
|
+
expect(state.entries.size).toBe(0);
|
|
18
|
+
expect(state.nextPasteNumber).toBe(1);
|
|
19
|
+
expect(state.nextImageNumber).toBe(1);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe('createPasteBlockEntry', () => {
|
|
23
|
+
it('adds paste block and returns marker', () => {
|
|
24
|
+
const state = createBlockState();
|
|
25
|
+
const { id, marker, state: newState } = createPasteBlockEntry(state, 'original text', '[Paste text #1]');
|
|
26
|
+
expect(id).toBeTruthy();
|
|
27
|
+
expect(marker).toContain('p:');
|
|
28
|
+
expect(marker).toContain(':1');
|
|
29
|
+
expect(newState.entries.size).toBe(1);
|
|
30
|
+
expect(newState.nextPasteNumber).toBe(2);
|
|
31
|
+
expect(newState.nextImageNumber).toBe(1);
|
|
32
|
+
const entry = newState.entries.get(id);
|
|
33
|
+
expect(entry.kind).toBe('paste');
|
|
34
|
+
if (entry.kind === 'paste') {
|
|
35
|
+
expect(entry.originalText).toBe('original text');
|
|
36
|
+
expect(entry.displayText).toBe('[Paste text #1]');
|
|
37
|
+
expect(entry.displayNumber).toBe(1);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
it('increments paste counter', () => {
|
|
41
|
+
const state = createBlockState();
|
|
42
|
+
const { state: s1 } = createPasteBlockEntry(state, 'a', '[#1]');
|
|
43
|
+
const { state: s2 } = createPasteBlockEntry(s1, 'b', '[#2]');
|
|
44
|
+
expect(s2.nextPasteNumber).toBe(3);
|
|
45
|
+
expect(s2.nextImageNumber).toBe(1);
|
|
46
|
+
expect(s2.entries.size).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
describe('createImageBlockEntry', () => {
|
|
50
|
+
it('adds image block and returns marker', () => {
|
|
51
|
+
const state = createBlockState();
|
|
52
|
+
const imgRef = makeImageRef();
|
|
53
|
+
const { id, marker, state: newState } = createImageBlockEntry(state, imgRef);
|
|
54
|
+
expect(id).toBe('img1');
|
|
55
|
+
expect(marker).toContain('i:');
|
|
56
|
+
expect(marker).toContain(':1');
|
|
57
|
+
expect(newState.entries.size).toBe(1);
|
|
58
|
+
expect(newState.nextImageNumber).toBe(2);
|
|
59
|
+
expect(newState.nextPasteNumber).toBe(1);
|
|
60
|
+
const entry = newState.entries.get(id);
|
|
61
|
+
expect(entry.kind).toBe('image');
|
|
62
|
+
if (entry.kind === 'image') {
|
|
63
|
+
expect(entry.data).toBe('');
|
|
64
|
+
expect(entry.mimeType).toBe('image/png');
|
|
65
|
+
expect(entry.byteSize).toBe(100);
|
|
66
|
+
expect(entry.displayNumber).toBe(1);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
it('increments image counter', () => {
|
|
70
|
+
const state = createBlockState();
|
|
71
|
+
const { state: s1 } = createImageBlockEntry(state, makeImageRef({ id: 'a' }));
|
|
72
|
+
const { state: s2 } = createImageBlockEntry(s1, makeImageRef({ id: 'b', displayNumber: 2 }));
|
|
73
|
+
expect(s2.nextImageNumber).toBe(3);
|
|
74
|
+
expect(s2.nextPasteNumber).toBe(1);
|
|
75
|
+
expect(s2.entries.size).toBe(2);
|
|
76
|
+
});
|
|
77
|
+
it('uses image ref displayNumber for marker and counter', () => {
|
|
78
|
+
const state = createBlockState();
|
|
79
|
+
const { marker, state: newState } = createImageBlockEntry(state, makeImageRef({ id: 'img5', displayNumber: 5 }));
|
|
80
|
+
expect(marker).toContain(':5');
|
|
81
|
+
expect(newState.nextImageNumber).toBe(6);
|
|
82
|
+
const entry = newState.entries.get('img5');
|
|
83
|
+
expect(entry.displayNumber).toBe(5);
|
|
84
|
+
});
|
|
85
|
+
it('paste and image counters are independent', () => {
|
|
86
|
+
const state = createBlockState();
|
|
87
|
+
const { state: s1 } = createPasteBlockEntry(state, 'text', '[Paste text #1]');
|
|
88
|
+
const { state: s2 } = createImageBlockEntry(s1, makeImageRef());
|
|
89
|
+
expect(s2.nextPasteNumber).toBe(2);
|
|
90
|
+
expect(s2.nextImageNumber).toBe(2);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe('removeBlock', () => {
|
|
94
|
+
it('removes block by id', () => {
|
|
95
|
+
const state = createBlockState();
|
|
96
|
+
const { id, state: s1 } = createPasteBlockEntry(state, 'a', '[#1]');
|
|
97
|
+
expect(s1.entries.size).toBe(1);
|
|
98
|
+
const s2 = removeBlock(s1, id);
|
|
99
|
+
expect(s2.entries.size).toBe(0);
|
|
100
|
+
});
|
|
101
|
+
it('does nothing for non-existent id', () => {
|
|
102
|
+
const state = createBlockState();
|
|
103
|
+
const s1 = removeBlock(state, 'nonexistent');
|
|
104
|
+
expect(s1.entries.size).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('getBlock', () => {
|
|
108
|
+
it('returns entry by id', () => {
|
|
109
|
+
const state = createBlockState();
|
|
110
|
+
const { id, state: s1 } = createPasteBlockEntry(state, 'a', '[#1]');
|
|
111
|
+
const entry = getBlock(s1, id);
|
|
112
|
+
expect(entry).toBeDefined();
|
|
113
|
+
expect(entry.kind).toBe('paste');
|
|
114
|
+
});
|
|
115
|
+
it('returns undefined for non-existent id', () => {
|
|
116
|
+
const state = createBlockState();
|
|
117
|
+
expect(getBlock(state, 'nope')).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe('getDisplayLine', () => {
|
|
121
|
+
it('expands paste marker to display text', () => {
|
|
122
|
+
const state = createBlockState();
|
|
123
|
+
const { marker, state: s1 } = createPasteBlockEntry(state, 'original', '[Custom #1]');
|
|
124
|
+
const line = `Hello ${marker} world`;
|
|
125
|
+
expect(getDisplayLine(line, s1.entries)).toBe('Hello [Custom #1] world');
|
|
126
|
+
});
|
|
127
|
+
it('expands image marker to display text', () => {
|
|
128
|
+
const state = createBlockState();
|
|
129
|
+
const { marker, state: s1 } = createImageBlockEntry(state, makeImageRef());
|
|
130
|
+
const line = `a${marker}b`;
|
|
131
|
+
expect(getDisplayLine(line, s1.entries)).toBe('a[Pasted Image #1]b');
|
|
132
|
+
});
|
|
133
|
+
it('returns line unchanged if no markers', () => {
|
|
134
|
+
const state = createBlockState();
|
|
135
|
+
expect(getDisplayLine('plain text', state.entries)).toBe('plain text');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
describe('getValue', () => {
|
|
139
|
+
it('expands paste markers to original text', () => {
|
|
140
|
+
const state = createBlockState();
|
|
141
|
+
const { marker, state: s1 } = createPasteBlockEntry(state, 'long original text', '[#1]');
|
|
142
|
+
const lines = [`Hello ${marker} world`];
|
|
143
|
+
expect(getValue(lines, s1.entries)).toBe('Hello long original text world');
|
|
144
|
+
});
|
|
145
|
+
it('leaves image markers as raw markers', () => {
|
|
146
|
+
const state = createBlockState();
|
|
147
|
+
const { marker, state: s1 } = createImageBlockEntry(state, makeImageRef());
|
|
148
|
+
const lines = [`a${marker}b`];
|
|
149
|
+
expect(getValue(lines, s1.entries)).toBe(`a${marker}b`);
|
|
150
|
+
});
|
|
151
|
+
it('handles multiple lines', () => {
|
|
152
|
+
const state = createBlockState();
|
|
153
|
+
const { marker, state: s1 } = createPasteBlockEntry(state, 'long', '[#1]');
|
|
154
|
+
const lines = [`line1 ${marker}`, 'line2'];
|
|
155
|
+
expect(getValue(lines, s1.entries)).toBe('line1 long\nline2');
|
|
156
|
+
});
|
|
157
|
+
it('returns original lines if no entries', () => {
|
|
158
|
+
const state = createBlockState();
|
|
159
|
+
expect(getValue(['hello'], state.entries)).toBe('hello');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe('bufferColToDisplayCol', () => {
|
|
163
|
+
it('handles plain text without markers', () => {
|
|
164
|
+
const state = createBlockState();
|
|
165
|
+
expect(bufferColToDisplayCol('hello', 3, state.entries)).toBe(3);
|
|
166
|
+
});
|
|
167
|
+
it('maps column before marker', () => {
|
|
168
|
+
const state = createBlockState();
|
|
169
|
+
const { marker, state: s1 } = createPasteBlockEntry(state, 'x', '[Display]');
|
|
170
|
+
const line = `ab${marker}cd`;
|
|
171
|
+
expect(bufferColToDisplayCol(line, 0, s1.entries)).toBe(0);
|
|
172
|
+
expect(bufferColToDisplayCol(line, 1, s1.entries)).toBe(1);
|
|
173
|
+
expect(bufferColToDisplayCol(line, 2, s1.entries)).toBe(2);
|
|
174
|
+
});
|
|
175
|
+
it('maps column within paste marker to display length', () => {
|
|
176
|
+
const state = createBlockState();
|
|
177
|
+
const { marker, state: s1 } = createPasteBlockEntry(state, 'x', '[Display]');
|
|
178
|
+
const line = `ab${marker}cd`;
|
|
179
|
+
// marker is at buffer positions 2-~15
|
|
180
|
+
const markers = parseBlockMarkers(line);
|
|
181
|
+
const m = markers[0];
|
|
182
|
+
// Any column within the marker should map to display col = 2 + 9 = 11
|
|
183
|
+
expect(bufferColToDisplayCol(line, m.start + 1, s1.entries)).toBe(11);
|
|
184
|
+
expect(bufferColToDisplayCol(line, m.end - 1, s1.entries)).toBe(11);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
describe('getValueCursorOffset', () => {
|
|
188
|
+
it('computes offset in expanded value', () => {
|
|
189
|
+
const state = createBlockState();
|
|
190
|
+
const { marker, state: s1 } = createPasteBlockEntry(state, 'hello', '[#1]');
|
|
191
|
+
const lines = [`${marker} world`];
|
|
192
|
+
// marker expands to 5 chars, so cursor at buffer position 0 maps to value position 0
|
|
193
|
+
expect(getValueCursorOffset(lines, { line: 0, column: 0 }, s1.entries)).toBe(0);
|
|
194
|
+
// Cursor after marker in buffer maps to value position after original text
|
|
195
|
+
const markers = parseBlockMarkers(marker);
|
|
196
|
+
expect(getValueCursorOffset(lines, { line: 0, column: markers[0].end }, s1.entries)).toBe(5);
|
|
197
|
+
expect(getValueCursorOffset(lines, { line: 0, column: markers[0].end + 1 }, s1.entries)).toBe(6);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe('getCursorFromValueOffset', () => {
|
|
201
|
+
it('converts value offset back to buffer cursor', () => {
|
|
202
|
+
const state = createBlockState();
|
|
203
|
+
const { marker, state: s1 } = createPasteBlockEntry(state, 'hello', '[#1]');
|
|
204
|
+
const lines = [`${marker} world`];
|
|
205
|
+
const markers = parseBlockMarkers(marker);
|
|
206
|
+
const cursor = getCursorFromValueOffset(lines, 5, s1.entries);
|
|
207
|
+
expect(cursor.line).toBe(0);
|
|
208
|
+
expect(cursor.column).toBe(markers[0].end);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
describe('displayColToBufferCol', () => {
|
|
212
|
+
it('handles plain text without markers', () => {
|
|
213
|
+
const state = createBlockState();
|
|
214
|
+
expect(displayColToBufferCol('hello', 3, state.entries)).toBe(3);
|
|
215
|
+
});
|
|
216
|
+
it('maps display column past marker to buffer column past marker', () => {
|
|
217
|
+
const state = createBlockState();
|
|
218
|
+
const { marker, state: s1 } = createPasteBlockEntry(state, 'x', '[Display]');
|
|
219
|
+
const line = `ab${marker}cd`;
|
|
220
|
+
const markers = parseBlockMarkers(line);
|
|
221
|
+
const displayLen = '[Display]'.length; // 9
|
|
222
|
+
// display position after marker: 2 + 9 = 11 → buffer after marker
|
|
223
|
+
expect(displayColToBufferCol(line, 2 + displayLen, s1.entries)).toBe(markers[0].end);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { renderHook, act } from '@testing-library/react';
|
|
3
3
|
import { useTextInput } from '../useTextInput.js';
|
|
4
|
+
import { parseBlockMarkers } from '../BlockMarker.js';
|
|
5
|
+
import { BLOCK_OPEN } from '../BlockMarker.js';
|
|
4
6
|
describe('Placeholder integration with useTextInput', () => {
|
|
5
7
|
const longText = 'x'.repeat(200);
|
|
6
8
|
it('creates a placeholder when pasting text exceeding pasteThreshold', () => {
|
|
@@ -11,13 +13,12 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
11
13
|
act(() => {
|
|
12
14
|
result.current.insert(longText);
|
|
13
15
|
});
|
|
14
|
-
|
|
15
|
-
expect(
|
|
16
|
-
|
|
16
|
+
const line = result.current.buffer.lines[0];
|
|
17
|
+
expect(line).toContain(BLOCK_OPEN);
|
|
18
|
+
expect(line).toContain('p:');
|
|
17
19
|
expect(result.current.value).toBe(longText);
|
|
18
|
-
// Cursor should be after the placeholder
|
|
19
20
|
const bufLine = result.current.buffer.lines[0];
|
|
20
|
-
const markerEnd = bufLine.length;
|
|
21
|
+
const markerEnd = bufLine.length;
|
|
21
22
|
expect(result.current.cursor).toEqual({ line: 0, column: markerEnd });
|
|
22
23
|
});
|
|
23
24
|
it('does not create placeholder when pasteThreshold is not set', () => {
|
|
@@ -27,7 +28,6 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
27
28
|
act(() => {
|
|
28
29
|
result.current.insert(longText);
|
|
29
30
|
});
|
|
30
|
-
// Should contain the actual text, not a marker
|
|
31
31
|
expect(result.current.value).toBe(longText);
|
|
32
32
|
expect(result.current.buffer.lines[0]).toBe(longText);
|
|
33
33
|
});
|
|
@@ -49,14 +49,12 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
49
49
|
undoDebounceMs: 0,
|
|
50
50
|
}));
|
|
51
51
|
act(() => { result.current.insert(longText); });
|
|
52
|
-
// Move cursor to end to insert more
|
|
53
52
|
act(() => { result.current.insert(' '); });
|
|
54
53
|
act(() => { result.current.insert(longText + longText); });
|
|
55
|
-
// Two placeholders should exist
|
|
56
54
|
const line = result.current.buffer.lines[0];
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const ids =
|
|
55
|
+
const markers = parseBlockMarkers(line);
|
|
56
|
+
expect(markers.length).toBeGreaterThanOrEqual(2);
|
|
57
|
+
const ids = markers.map(m => m.id);
|
|
60
58
|
expect(new Set(ids).size).toBe(ids.length);
|
|
61
59
|
});
|
|
62
60
|
it('value returns original text with multiple placeholders', () => {
|
|
@@ -78,8 +76,6 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
78
76
|
}));
|
|
79
77
|
act(() => { result.current.insert(longText); });
|
|
80
78
|
expect(result.current.value).toBe(longText);
|
|
81
|
-
expect(result.current.buffer.lines[0]).toContain('\x00P0\x00');
|
|
82
|
-
// Backspace should remove the whole placeholder
|
|
83
79
|
act(() => { result.current.delete(); });
|
|
84
80
|
expect(result.current.value).toBe('');
|
|
85
81
|
expect(result.current.buffer.lines[0]).toBe('');
|
|
@@ -90,17 +86,13 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
90
86
|
pasteThreshold: 10,
|
|
91
87
|
undoDebounceMs: 0,
|
|
92
88
|
}));
|
|
93
|
-
// Insert text, then go back to start
|
|
94
89
|
act(() => { result.current.insert('A'); });
|
|
95
90
|
act(() => { result.current.insert(longText); });
|
|
96
|
-
// Move cursor to start
|
|
97
91
|
act(() => { result.current.moveCursor('lineStart'); });
|
|
98
92
|
expect(result.current.cursor).toEqual({ line: 0, column: 0 });
|
|
99
|
-
// Forward delete should delete 'A' first
|
|
100
93
|
act(() => { result.current.deleteForward(); });
|
|
101
94
|
expect(result.current.value).toBe(longText);
|
|
102
95
|
expect(result.current.cursor).toEqual({ line: 0, column: 0 });
|
|
103
|
-
// Forward delete again should delete the placeholder
|
|
104
96
|
act(() => { result.current.deleteForward(); });
|
|
105
97
|
expect(result.current.value).toBe('');
|
|
106
98
|
expect(result.current.buffer.lines[0]).toBe('');
|
|
@@ -113,14 +105,17 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
113
105
|
act(() => { result.current.insert('A'); });
|
|
114
106
|
act(() => { result.current.insert(longText); });
|
|
115
107
|
act(() => { result.current.insert('B'); });
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
108
|
+
const line = result.current.buffer.lines[0];
|
|
109
|
+
const markers = parseBlockMarkers(line);
|
|
110
|
+
const marker = markers[0];
|
|
111
|
+
const endCol = marker.end + 1; // 1 for B
|
|
112
|
+
expect(result.current.cursor.column).toBe(endCol);
|
|
113
|
+
// Move left to position after marker (before B)
|
|
119
114
|
act(() => { result.current.moveCursor('left'); });
|
|
120
|
-
expect(result.current.cursor.column).toBe(
|
|
121
|
-
// Move left again -
|
|
115
|
+
expect(result.current.cursor.column).toBe(marker.end);
|
|
116
|
+
// Move left again - should jump to marker start
|
|
122
117
|
act(() => { result.current.moveCursor('left'); });
|
|
123
|
-
expect(result.current.cursor.column).toBe(
|
|
118
|
+
expect(result.current.cursor.column).toBe(marker.start);
|
|
124
119
|
});
|
|
125
120
|
it('right arrow jumps over placeholder', () => {
|
|
126
121
|
const { result } = renderHook(() => useTextInput({
|
|
@@ -130,15 +125,17 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
130
125
|
act(() => { result.current.insert('A'); });
|
|
131
126
|
act(() => { result.current.insert(longText); });
|
|
132
127
|
act(() => { result.current.insert('B'); });
|
|
133
|
-
// Move cursor to start
|
|
134
128
|
act(() => { result.current.moveCursor('lineStart'); });
|
|
135
129
|
expect(result.current.cursor.column).toBe(0);
|
|
136
|
-
|
|
130
|
+
const line = result.current.buffer.lines[0];
|
|
131
|
+
const markers = parseBlockMarkers(line);
|
|
132
|
+
const marker = markers[0];
|
|
133
|
+
// Move right past 'A'
|
|
137
134
|
act(() => { result.current.moveCursor('right'); });
|
|
138
|
-
expect(result.current.cursor.column).toBe(
|
|
139
|
-
// Move right again -
|
|
135
|
+
expect(result.current.cursor.column).toBe(marker.start);
|
|
136
|
+
// Move right again - should jump to marker end
|
|
140
137
|
act(() => { result.current.moveCursor('right'); });
|
|
141
|
-
expect(result.current.cursor.column).toBe(
|
|
138
|
+
expect(result.current.cursor.column).toBe(marker.end);
|
|
142
139
|
});
|
|
143
140
|
it('undo restores state before placeholder creation', () => {
|
|
144
141
|
const { result } = renderHook(() => useTextInput({
|
|
@@ -148,11 +145,9 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
148
145
|
act(() => { result.current.insert('A'); });
|
|
149
146
|
act(() => { result.current.insert(longText); });
|
|
150
147
|
expect(result.current.value).toBe('A' + longText);
|
|
151
|
-
// Undo should remove the paste
|
|
152
148
|
act(() => { result.current.undo(); });
|
|
153
149
|
expect(result.current.value).toBe('A');
|
|
154
150
|
expect(result.current.cursor).toEqual({ line: 0, column: 1 });
|
|
155
|
-
// Undo again should remove 'A'
|
|
156
151
|
act(() => { result.current.undo(); });
|
|
157
152
|
expect(result.current.value).toBe('');
|
|
158
153
|
});
|
|
@@ -164,13 +159,11 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
164
159
|
act(() => { result.current.insert('A'); });
|
|
165
160
|
act(() => { result.current.insert(longText); });
|
|
166
161
|
expect(result.current.value).toBe('A' + longText);
|
|
167
|
-
// Undo the placeholder creation
|
|
168
162
|
act(() => { result.current.undo(); });
|
|
169
163
|
expect(result.current.value).toBe('A');
|
|
170
|
-
// Redo should bring back the placeholder
|
|
171
164
|
act(() => { result.current.redo(); });
|
|
172
165
|
expect(result.current.value).toBe('A' + longText);
|
|
173
|
-
expect(result.current.buffer.lines[0]).toContain(
|
|
166
|
+
expect(result.current.buffer.lines[0]).toContain(BLOCK_OPEN);
|
|
174
167
|
});
|
|
175
168
|
it('handles multi-line original text', () => {
|
|
176
169
|
const { result } = renderHook(() => useTextInput({
|
|
@@ -179,10 +172,8 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
179
172
|
}));
|
|
180
173
|
const multiLineText = 'hello\nworld\nfoo';
|
|
181
174
|
act(() => { result.current.insert(multiLineText); });
|
|
182
|
-
// Value should contain the original multi-line text
|
|
183
175
|
expect(result.current.value).toBe(multiLineText);
|
|
184
|
-
|
|
185
|
-
expect(result.current.buffer.lines[0]).toContain('\x00P0\x00');
|
|
176
|
+
expect(result.current.buffer.lines[0]).toContain(BLOCK_OPEN);
|
|
186
177
|
expect(result.current.buffer.lines.length).toBe(1);
|
|
187
178
|
});
|
|
188
179
|
it('typing after placeholder works correctly', () => {
|
|
@@ -194,7 +185,7 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
194
185
|
act(() => { result.current.insert(' and more'); });
|
|
195
186
|
expect(result.current.value).toBe(longText + ' and more');
|
|
196
187
|
const line = result.current.buffer.lines[0];
|
|
197
|
-
expect(line).toContain(
|
|
188
|
+
expect(line).toContain(BLOCK_OPEN);
|
|
198
189
|
expect(line).toContain(' and more');
|
|
199
190
|
});
|
|
200
191
|
it('typing before placeholder works correctly', () => {
|
|
@@ -203,12 +194,11 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
203
194
|
undoDebounceMs: 0,
|
|
204
195
|
}));
|
|
205
196
|
act(() => { result.current.insert(longText); });
|
|
206
|
-
// Move cursor to start
|
|
207
197
|
act(() => { result.current.moveCursor('lineStart'); });
|
|
208
198
|
act(() => { result.current.insert('prefix '); });
|
|
209
199
|
expect(result.current.value).toBe('prefix ' + longText);
|
|
210
200
|
const line = result.current.buffer.lines[0];
|
|
211
|
-
expect(line).toContain(
|
|
201
|
+
expect(line).toContain(BLOCK_OPEN);
|
|
212
202
|
expect(line).toContain('prefix ');
|
|
213
203
|
});
|
|
214
204
|
it('setText clears placeholders', () => {
|
|
@@ -217,24 +207,25 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
217
207
|
undoDebounceMs: 0,
|
|
218
208
|
}));
|
|
219
209
|
act(() => { result.current.insert(longText); });
|
|
220
|
-
expect(result.current.
|
|
210
|
+
expect(result.current.blockState.entries.size).toBe(1);
|
|
221
211
|
act(() => { result.current.setText('new text'); });
|
|
222
212
|
expect(result.current.value).toBe('new text');
|
|
223
|
-
expect(result.current.
|
|
213
|
+
expect(result.current.blockState.entries.size).toBe(0);
|
|
224
214
|
});
|
|
225
215
|
it('custom formatPastePlaceholder is used for display text', () => {
|
|
226
|
-
const formatter = (
|
|
216
|
+
const formatter = (displayNumber) => `📋Pasted#${displayNumber}📋`;
|
|
227
217
|
const { result } = renderHook(() => useTextInput({
|
|
228
218
|
pasteThreshold: 10,
|
|
229
219
|
formatPastePlaceholder: formatter,
|
|
230
220
|
undoDebounceMs: 0,
|
|
231
221
|
}));
|
|
232
222
|
act(() => { result.current.insert(longText); });
|
|
233
|
-
// Value should still have original text
|
|
234
223
|
expect(result.current.value).toBe(longText);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
224
|
+
const entry = result.current.blockState.entries.values().next().value;
|
|
225
|
+
expect(entry.kind).toBe('paste');
|
|
226
|
+
if (entry.kind === 'paste') {
|
|
227
|
+
expect(entry.displayText).toBe('📋Pasted#1📋');
|
|
228
|
+
}
|
|
238
229
|
});
|
|
239
230
|
it('placeholder counter is per useTextInput instance', () => {
|
|
240
231
|
const { result: r1 } = renderHook(() => useTextInput({
|
|
@@ -249,19 +240,17 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
249
240
|
r1.current.insert(longText);
|
|
250
241
|
r2.current.insert(longText);
|
|
251
242
|
});
|
|
252
|
-
expect(r1.current.
|
|
253
|
-
expect(r2.current.
|
|
243
|
+
expect(r1.current.blockState.entries.size).toBe(1);
|
|
244
|
+
expect(r2.current.blockState.entries.size).toBe(1);
|
|
254
245
|
});
|
|
255
246
|
it('strips \x00 from user input so it cannot impersonate a placeholder marker', () => {
|
|
256
247
|
const { result } = renderHook(() => useTextInput({
|
|
257
248
|
undoDebounceMs: 0,
|
|
258
249
|
}));
|
|
259
|
-
// Forge what looks like a marker for id 0
|
|
260
250
|
act(() => { result.current.insert('hello\x00P0\x00world'); });
|
|
261
|
-
// \x00 bytes are stripped, leaving plain text — no fake marker in buffer or value
|
|
262
251
|
expect(result.current.buffer.lines[0]).toBe('helloP0world');
|
|
263
252
|
expect(result.current.value).toBe('helloP0world');
|
|
264
|
-
expect(result.current.
|
|
253
|
+
expect(result.current.blockState.entries.size).toBe(0);
|
|
265
254
|
});
|
|
266
255
|
it('backspace at line start merges with previous line containing placeholder', () => {
|
|
267
256
|
const { result } = renderHook(() => useTextInput({
|
|
@@ -270,19 +259,14 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
270
259
|
}));
|
|
271
260
|
act(() => { result.current.insert(longText); });
|
|
272
261
|
act(() => { result.current.insert('\n'); });
|
|
273
|
-
act(() => { result.current.insert('abc'); });
|
|
274
|
-
|
|
275
|
-
expect(result.current.cursor).toEqual({ line: 1, column: 3 }); // 'abc' = 3 chars
|
|
276
|
-
// Move to start of second line
|
|
262
|
+
act(() => { result.current.insert('abc'); });
|
|
263
|
+
expect(result.current.cursor).toEqual({ line: 1, column: 3 });
|
|
277
264
|
for (let i = 0; i < 3; i++) {
|
|
278
265
|
act(() => { result.current.moveCursor('left'); });
|
|
279
266
|
}
|
|
280
267
|
expect(result.current.cursor).toEqual({ line: 1, column: 0 });
|
|
281
|
-
// Backspace should merge lines
|
|
282
268
|
act(() => { result.current.delete(); });
|
|
283
269
|
expect(result.current.cursor.line).toBe(0);
|
|
284
|
-
// After merge: "\x00P0\x00abc" = 7 chars, cursor at previousLine.length = 4
|
|
285
|
-
expect(result.current.cursor.column).toBe(4);
|
|
286
270
|
expect(result.current.value).toBe(longText + 'abc');
|
|
287
271
|
});
|
|
288
272
|
it('delete at end of line merges with next line containing placeholder', () => {
|
|
@@ -290,13 +274,11 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
290
274
|
pasteThreshold: 10,
|
|
291
275
|
undoDebounceMs: 0,
|
|
292
276
|
}));
|
|
293
|
-
act(() => { result.current.insert('hi'); });
|
|
277
|
+
act(() => { result.current.insert('hi'); });
|
|
294
278
|
act(() => { result.current.insert('\n'); });
|
|
295
279
|
act(() => { result.current.insert(longText); });
|
|
296
|
-
// Move to end of first line
|
|
297
280
|
act(() => { result.current.moveCursor('up'); });
|
|
298
|
-
expect(result.current.cursor).toEqual({ line: 0, column: 2 });
|
|
299
|
-
// Forward delete should merge lines
|
|
281
|
+
expect(result.current.cursor).toEqual({ line: 0, column: 2 });
|
|
300
282
|
act(() => { result.current.deleteForward(); });
|
|
301
283
|
expect(result.current.value).toBe('hi' + longText);
|
|
302
284
|
});
|
|
@@ -307,12 +289,9 @@ describe('Placeholder integration with useTextInput', () => {
|
|
|
307
289
|
}));
|
|
308
290
|
act(() => { result.current.insert('A'); });
|
|
309
291
|
const longLen = longText.length;
|
|
310
|
-
// Before paste, cursor offset = 1
|
|
311
292
|
expect(result.current.cursorOffset).toBe(1);
|
|
312
293
|
act(() => { result.current.insert(longText); });
|
|
313
|
-
// After paste, cursor offset should be 1 + longLen (after the expanded text)
|
|
314
294
|
expect(result.current.cursorOffset).toBe(1 + longLen);
|
|
315
|
-
// Value is A + longText (length 1 + longLen)
|
|
316
295
|
expect(result.current.value.length).toBe(1 + longLen);
|
|
317
296
|
});
|
|
318
297
|
});
|
|
@@ -1,39 +1,29 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { createBuffer, insertText, deleteChar, deleteCharForward, moveCursor, getVisualRows, getTextContent, } from '../TextBuffer.js';
|
|
3
|
-
import {
|
|
3
|
+
import { createBlockMarker, getBlockPlaceholderText } from '../BlockMarker.js';
|
|
4
4
|
describe('TextBuffer with sentinels', () => {
|
|
5
|
-
const sentinel1 =
|
|
6
|
-
const sentinel2 =
|
|
7
|
-
const placeholder1 =
|
|
8
|
-
const placeholder2 =
|
|
5
|
+
const sentinel1 = createBlockMarker('i', 'img1', 1);
|
|
6
|
+
const sentinel2 = createBlockMarker('i', 'img2', 2);
|
|
7
|
+
const placeholder1 = getBlockPlaceholderText('i', 1); // "[Pasted Image #1]" (17 chars)
|
|
8
|
+
const placeholder2 = getBlockPlaceholderText('i', 2); // "[Pasted Image #2]" (17 chars)
|
|
9
9
|
describe('getVisualRows', () => {
|
|
10
10
|
it('treats sentinel as atomic block with placeholder visual width', () => {
|
|
11
11
|
const line = `${sentinel1}`;
|
|
12
12
|
const rows = getVisualRows(line, 20);
|
|
13
|
-
// The sentinel takes up placeholder text width (17)
|
|
14
13
|
expect(rows).toEqual([
|
|
15
|
-
{ start: 0, length: sentinel1.length },
|
|
14
|
+
{ start: 0, length: sentinel1.length },
|
|
16
15
|
]);
|
|
17
16
|
});
|
|
18
17
|
it('does not split a sentinel block across visual rows', () => {
|
|
19
|
-
// Sentinel (17 visual width) + "abc" (3) = 20 total, use width 20
|
|
20
18
|
const line = `${sentinel1}abc`;
|
|
21
19
|
const rows = getVisualRows(line, 20);
|
|
22
|
-
// All fits on one row
|
|
23
20
|
expect(rows.length).toBe(1);
|
|
24
21
|
expect(rows[0].start).toBe(0);
|
|
25
22
|
expect(rows[0].length).toBe(line.length);
|
|
26
23
|
});
|
|
27
24
|
it('wraps text around sentinel blocks', () => {
|
|
28
|
-
// A sentinel in the middle, wrap at a narrow width
|
|
29
25
|
const line = `a${sentinel1}b`;
|
|
30
26
|
const rows = getVisualRows(line, 3);
|
|
31
|
-
// Each sentinel block is atomic and takes up its full visual width
|
|
32
|
-
// "a" (1) + sentinel (5 actual chars but 17 visual width) = 18 > 3, so splits
|
|
33
|
-
// The sentinel can't be split so it goes on its own row
|
|
34
|
-
// But "a" (1) fits in first row
|
|
35
|
-
// Then sentinel (17 visual) might need to be on its own row
|
|
36
|
-
// Then "b" (1) on next
|
|
37
27
|
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
38
28
|
});
|
|
39
29
|
it('renders empty array for empty line', () => {
|
|
@@ -44,14 +34,13 @@ describe('TextBuffer with sentinels', () => {
|
|
|
44
34
|
describe('moveCursor left with sentinels', () => {
|
|
45
35
|
it('jumps over sentinel when cursor is right after it', () => {
|
|
46
36
|
const buffer = createBuffer(`hello${sentinel1}world`);
|
|
47
|
-
const cursor = { line: 0, column: `hello${sentinel1}`.length };
|
|
37
|
+
const cursor = { line: 0, column: `hello${sentinel1}`.length };
|
|
48
38
|
const result = moveCursor(buffer, cursor, 'left');
|
|
49
|
-
// Should jump to before the sentinel opener (after "hello")
|
|
50
39
|
expect(result).toEqual({ line: 0, column: 5 });
|
|
51
40
|
});
|
|
52
41
|
it('normal left movement when not adjacent to sentinel', () => {
|
|
53
42
|
const buffer = createBuffer(`${sentinel1}world`);
|
|
54
|
-
const cursor = { line: 0, column: `${sentinel1}world`.length };
|
|
43
|
+
const cursor = { line: 0, column: `${sentinel1}world`.length };
|
|
55
44
|
const result = moveCursor(buffer, cursor, 'left');
|
|
56
45
|
expect(result).toEqual({ line: 0, column: `${sentinel1}worl`.length });
|
|
57
46
|
});
|
|
@@ -59,9 +48,8 @@ describe('TextBuffer with sentinels', () => {
|
|
|
59
48
|
describe('moveCursor right with sentinels', () => {
|
|
60
49
|
it('jumps over sentinel when cursor is right before it', () => {
|
|
61
50
|
const buffer = createBuffer(`hello${sentinel1}world`);
|
|
62
|
-
const cursor = { line: 0, column: 5 };
|
|
51
|
+
const cursor = { line: 0, column: 5 };
|
|
63
52
|
const result = moveCursor(buffer, cursor, 'right');
|
|
64
|
-
// Should jump to after the sentinel closer
|
|
65
53
|
expect(result).toEqual({ line: 0, column: `hello${sentinel1}`.length });
|
|
66
54
|
});
|
|
67
55
|
it('normal right movement when not adjacent to sentinel', () => {
|
|
@@ -74,18 +62,12 @@ describe('TextBuffer with sentinels', () => {
|
|
|
74
62
|
describe('deleteChar (backspace) with sentinels', () => {
|
|
75
63
|
it('removes whole sentinel block when deleting at closer', () => {
|
|
76
64
|
const buffer = createBuffer(`hello${sentinel1}world`);
|
|
77
|
-
// Use getTextContent to find offset
|
|
78
|
-
// Cursor: right after sentinel (at end of sentinel block)
|
|
79
65
|
const cursor = { line: 0, column: `hello${sentinel1}`.length };
|
|
80
66
|
const result = deleteChar(buffer, cursor);
|
|
81
67
|
expect(getTextContent(result.buffer)).toBe('helloworld');
|
|
82
68
|
expect(result.cursor).toEqual({ line: 0, column: 5 });
|
|
83
69
|
});
|
|
84
70
|
it('normal backspace when not near sentinel', () => {
|
|
85
|
-
const buffer = createBuffer(`hello${sentinel1}`);
|
|
86
|
-
const cursor = { line: 0, column: `hello${sentinel1}`.length };
|
|
87
|
-
// This is right after sentinel, so the first backspace removes the sentinel
|
|
88
|
-
// Just test a normal backspace on regular text
|
|
89
71
|
const normalBuffer = createBuffer('hello');
|
|
90
72
|
const normalCursor = { line: 0, column: 5 };
|
|
91
73
|
const result = deleteChar(normalBuffer, normalCursor);
|
|
@@ -95,7 +77,7 @@ describe('TextBuffer with sentinels', () => {
|
|
|
95
77
|
describe('deleteCharForward (delete key) with sentinels', () => {
|
|
96
78
|
it('removes whole sentinel block when cursor is at opener', () => {
|
|
97
79
|
const buffer = createBuffer(`hello${sentinel1}world`);
|
|
98
|
-
const cursor = { line: 0, column: 5 };
|
|
80
|
+
const cursor = { line: 0, column: 5 };
|
|
99
81
|
const result = deleteCharForward(buffer, cursor);
|
|
100
82
|
expect(getTextContent(result.buffer)).toBe('helloworld');
|
|
101
83
|
expect(result.cursor).toEqual({ line: 0, column: 5 });
|
|
@@ -124,12 +106,9 @@ describe('TextBuffer with sentinels', () => {
|
|
|
124
106
|
describe('multiple sentinels', () => {
|
|
125
107
|
it('handles two sentinels with text between', () => {
|
|
126
108
|
const buffer = createBuffer(`${sentinel1}text${sentinel2}`);
|
|
127
|
-
// Cursor right after sentinel2
|
|
128
109
|
const cursor = { line: 0, column: `${sentinel1}text${sentinel2}`.length };
|
|
129
|
-
// Move left should jump over sentinel2
|
|
130
110
|
const leftOnce = moveCursor(buffer, cursor, 'left');
|
|
131
111
|
expect(leftOnce).toEqual({ line: 0, column: `${sentinel1}text`.length });
|
|
132
|
-
// Move left again should be normal (inside "text")
|
|
133
112
|
const leftTwice = moveCursor(buffer, leftOnce, 'left');
|
|
134
113
|
expect(leftTwice).toEqual({ line: 0, column: `${sentinel1}tex`.length });
|
|
135
114
|
});
|