ink-prompt 0.2.3 → 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 +2 -11
- 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
|
@@ -2,14 +2,28 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { describe, it, expect } from 'vitest';
|
|
3
3
|
import { render } from '@testing-library/react';
|
|
4
4
|
import { TextRenderer, wrapLines } from '../TextRenderer.js';
|
|
5
|
-
import {
|
|
5
|
+
import { createBlockMarker } from '../BlockMarker.js';
|
|
6
|
+
function makeBlockState(entries) {
|
|
7
|
+
const map = new Map();
|
|
8
|
+
for (const e of entries) {
|
|
9
|
+
map.set(e.id, {
|
|
10
|
+
kind: 'image',
|
|
11
|
+
id: e.id,
|
|
12
|
+
displayNumber: e.displayNumber,
|
|
13
|
+
data: '',
|
|
14
|
+
mimeType: 'image/png',
|
|
15
|
+
byteSize: 100,
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return { entries: map, nextPasteNumber: 1, nextImageNumber: 3 };
|
|
19
|
+
}
|
|
6
20
|
describe('TextRenderer with images', () => {
|
|
7
|
-
const sentinel1 =
|
|
8
|
-
const sentinel2 =
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
21
|
+
const sentinel1 = createBlockMarker('i', 'img1', 1);
|
|
22
|
+
const sentinel2 = createBlockMarker('i', 'img2', 2);
|
|
23
|
+
const blockState = makeBlockState([
|
|
24
|
+
{ id: 'img1', displayNumber: 1 },
|
|
25
|
+
{ id: 'img2', displayNumber: 2 },
|
|
26
|
+
]);
|
|
13
27
|
describe('wrapLines', () => {
|
|
14
28
|
it('renders normal text unchanged when no sentinels', () => {
|
|
15
29
|
const buffer = { lines: ['hello'] };
|
|
@@ -46,26 +60,26 @@ describe('TextRenderer with images', () => {
|
|
|
46
60
|
it('renders sentinel as dimmed placeholder text', () => {
|
|
47
61
|
const buffer = { lines: [sentinel1] };
|
|
48
62
|
const cursor = { line: 0, column: sentinel1.length };
|
|
49
|
-
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor,
|
|
63
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, blockState: blockState, showCursor: false }));
|
|
50
64
|
expect(container.textContent).toContain('[Pasted Image #1]');
|
|
51
65
|
});
|
|
52
66
|
it('renders multiple sentinels', () => {
|
|
53
67
|
const buffer = { lines: [`${sentinel1} ${sentinel2}`] };
|
|
54
68
|
const cursor = { line: 0, column: 0 };
|
|
55
|
-
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor,
|
|
69
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, blockState: blockState, showCursor: false }));
|
|
56
70
|
expect(container.textContent).toContain('[Pasted Image #1]');
|
|
57
71
|
expect(container.textContent).toContain('[Pasted Image #2]');
|
|
58
72
|
});
|
|
59
73
|
it('renders cursor before sentinel', () => {
|
|
60
74
|
const buffer = { lines: [sentinel1] };
|
|
61
|
-
const cursor = { line: 0, column: 0 };
|
|
62
|
-
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor,
|
|
75
|
+
const cursor = { line: 0, column: 0 };
|
|
76
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, blockState: blockState, showCursor: true }));
|
|
63
77
|
expect(container.textContent).toContain('[Pasted Image #1]');
|
|
64
78
|
});
|
|
65
79
|
it('renders cursor after sentinel', () => {
|
|
66
80
|
const buffer = { lines: [sentinel1] };
|
|
67
|
-
const cursor = { line: 0, column: sentinel1.length };
|
|
68
|
-
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor,
|
|
81
|
+
const cursor = { line: 0, column: sentinel1.length };
|
|
82
|
+
const { container } = render(_jsx(TextRenderer, { buffer: buffer, cursor: cursor, blockState: blockState, showCursor: true }));
|
|
69
83
|
expect(container.textContent).toContain('[Pasted Image #1]');
|
|
70
84
|
});
|
|
71
85
|
});
|
|
@@ -2,9 +2,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { describe, it, expect } from 'vitest';
|
|
3
3
|
import { render } from '@testing-library/react';
|
|
4
4
|
import { MultilineInputCore } from '../index.js';
|
|
5
|
-
import {
|
|
5
|
+
import { createBlockMarker } from '../BlockMarker.js';
|
|
6
6
|
describe('MultilineInputCore with images', () => {
|
|
7
|
-
const sentinel1 =
|
|
7
|
+
const sentinel1 = createBlockMarker('i', 'img1', 1);
|
|
8
8
|
const img1 = {
|
|
9
9
|
id: 'img1',
|
|
10
10
|
data: 'base64data',
|
|
@@ -26,8 +26,6 @@ describe('MultilineInputCore with images', () => {
|
|
|
26
26
|
});
|
|
27
27
|
describe('submit with images', () => {
|
|
28
28
|
it('submit passes images when provided via textInput', () => {
|
|
29
|
-
// We test that the component renders with images correctly
|
|
30
|
-
// The actual submit behavior with images is tested in useTextInput_images tests
|
|
31
29
|
const { container } = render(_jsx(MultilineInputCore, { value: sentinel1, images: [img1], showCursor: false }));
|
|
32
30
|
expect(container.textContent).toContain('[Pasted Image #1]');
|
|
33
31
|
});
|
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
import { renderHook, act } from '@testing-library/react';
|
|
2
2
|
import { describe, it, expect } from 'vitest';
|
|
3
3
|
import { useTextInput } from '../useTextInput.js';
|
|
4
|
-
import {
|
|
4
|
+
import { createBlockMarker } from '../BlockMarker.js';
|
|
5
5
|
describe('useTextInput with images', () => {
|
|
6
6
|
const makeImageRef = (id, displayNumber) => ({
|
|
7
7
|
id,
|
|
8
8
|
data: 'base64data',
|
|
9
9
|
mimeType: 'image/png',
|
|
10
10
|
byteSize: 100,
|
|
11
|
-
displayNumber,
|
|
11
|
+
displayNumber: displayNumber || 1,
|
|
12
12
|
});
|
|
13
13
|
describe('insertImage', () => {
|
|
14
14
|
it('adds sentinel to text and image to map', () => {
|
|
15
15
|
const { result } = renderHook(() => useTextInput());
|
|
16
|
-
const imgRef = makeImageRef('img1'
|
|
16
|
+
const imgRef = makeImageRef('img1');
|
|
17
17
|
act(() => {
|
|
18
18
|
result.current.insertImage(imgRef);
|
|
19
19
|
});
|
|
20
|
-
|
|
21
|
-
expect(result.current.
|
|
22
|
-
|
|
20
|
+
expect(result.current.getImages()).toHaveLength(1);
|
|
21
|
+
expect(result.current.getImages()[0].id).toBe('img1');
|
|
22
|
+
const line = result.current.buffer.lines[0];
|
|
23
|
+
expect(line).toContain(createBlockMarker('i', 'img1', 1));
|
|
23
24
|
});
|
|
24
25
|
it('assigns correct displayNumber', () => {
|
|
25
26
|
const { result } = renderHook(() => useTextInput());
|
|
@@ -32,15 +33,25 @@ describe('useTextInput with images', () => {
|
|
|
32
33
|
expect(images[0].displayNumber).toBe(1);
|
|
33
34
|
expect(images[1].displayNumber).toBe(2);
|
|
34
35
|
});
|
|
36
|
+
it('continues numbering after controlled images are synced', () => {
|
|
37
|
+
const { result } = renderHook(() => useTextInput());
|
|
38
|
+
act(() => {
|
|
39
|
+
result.current.setImages([makeImageRef('img5', 5)]);
|
|
40
|
+
});
|
|
41
|
+
act(() => {
|
|
42
|
+
result.current.insertImage(makeImageRef('img6', 6));
|
|
43
|
+
});
|
|
44
|
+
expect(result.current.getImages().map((img) => img.displayNumber)).toEqual([5, 6]);
|
|
45
|
+
expect(result.current.buffer.lines[0]).toContain(createBlockMarker('i', 'img6', 6));
|
|
46
|
+
});
|
|
35
47
|
});
|
|
36
48
|
describe('deleteChar removes image and sentinel', () => {
|
|
37
49
|
it('removes sentinel and image from map on backspace', () => {
|
|
38
50
|
const { result } = renderHook(() => useTextInput());
|
|
39
|
-
const imgRef = makeImageRef('img1'
|
|
51
|
+
const imgRef = makeImageRef('img1');
|
|
40
52
|
act(() => { result.current.insertImage(imgRef); });
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
expect(result.current.cursor.column).toBe(sentinel.length);
|
|
53
|
+
const markerLen = createBlockMarker('i', 'img1', 1).length;
|
|
54
|
+
expect(result.current.cursor.column).toBe(markerLen);
|
|
44
55
|
act(() => {
|
|
45
56
|
result.current.delete();
|
|
46
57
|
});
|
|
@@ -51,17 +62,14 @@ describe('useTextInput with images', () => {
|
|
|
51
62
|
describe('deleteForward removes image and sentinel', () => {
|
|
52
63
|
it('removes sentinel and image from map on delete key', () => {
|
|
53
64
|
const { result } = renderHook(() => useTextInput());
|
|
54
|
-
const imgRef = makeImageRef('img1'
|
|
65
|
+
const imgRef = makeImageRef('img1');
|
|
55
66
|
act(() => { result.current.insertImage(imgRef); });
|
|
56
|
-
// Move cursor to before the sentinel
|
|
57
67
|
act(() => { result.current.moveCursor('left'); });
|
|
58
68
|
act(() => { result.current.moveCursor('left'); });
|
|
59
|
-
// Cursor should now be before the sentinel
|
|
60
69
|
expect(result.current.cursor.column).toBe(0);
|
|
61
70
|
act(() => {
|
|
62
71
|
result.current.deleteForward();
|
|
63
72
|
});
|
|
64
|
-
// Should have deleted the whole sentinel
|
|
65
73
|
expect(result.current.value).toBe('');
|
|
66
74
|
expect(result.current.getImages()).toEqual([]);
|
|
67
75
|
});
|
|
@@ -69,7 +77,7 @@ describe('useTextInput with images', () => {
|
|
|
69
77
|
describe('undo/redo with images', () => {
|
|
70
78
|
it('undo restores deleted image', () => {
|
|
71
79
|
const { result } = renderHook(() => useTextInput({ undoDebounceMs: 0 }));
|
|
72
|
-
const imgRef = makeImageRef('img1'
|
|
80
|
+
const imgRef = makeImageRef('img1');
|
|
73
81
|
act(() => { result.current.insertImage(imgRef); });
|
|
74
82
|
expect(result.current.getImages()).toHaveLength(1);
|
|
75
83
|
act(() => { result.current.delete(); });
|
|
@@ -80,7 +88,7 @@ describe('useTextInput with images', () => {
|
|
|
80
88
|
});
|
|
81
89
|
it('redo re-applies image insert', () => {
|
|
82
90
|
const { result } = renderHook(() => useTextInput({ undoDebounceMs: 0 }));
|
|
83
|
-
const imgRef = makeImageRef('img1'
|
|
91
|
+
const imgRef = makeImageRef('img1');
|
|
84
92
|
act(() => { result.current.insertImage(imgRef); });
|
|
85
93
|
act(() => { result.current.undo(); });
|
|
86
94
|
expect(result.current.getImages()).toHaveLength(0);
|
|
@@ -90,21 +98,14 @@ describe('useTextInput with images', () => {
|
|
|
90
98
|
});
|
|
91
99
|
});
|
|
92
100
|
describe('setText cleans up orphaned sentinels', () => {
|
|
93
|
-
it('
|
|
101
|
+
it('setText clears block entries', () => {
|
|
94
102
|
const { result } = renderHook(() => useTextInput());
|
|
95
|
-
const img1 = makeImageRef('img1'
|
|
96
|
-
const img2 = makeImageRef('img2', 2);
|
|
103
|
+
const img1 = makeImageRef('img1');
|
|
97
104
|
act(() => { result.current.insertImage(img1); });
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
act(() => {
|
|
103
|
-
result.current.setText(sentinel1 + ' extra');
|
|
104
|
-
});
|
|
105
|
-
const images = result.current.getImages();
|
|
106
|
-
expect(images).toHaveLength(1);
|
|
107
|
-
expect(images[0].id).toBe('img1');
|
|
105
|
+
expect(result.current.getImages()).toHaveLength(1);
|
|
106
|
+
act(() => { result.current.setText('new text'); });
|
|
107
|
+
expect(result.current.getImages()).toHaveLength(0);
|
|
108
|
+
expect(result.current.value).toBe('new text');
|
|
108
109
|
});
|
|
109
110
|
});
|
|
110
111
|
});
|
|
@@ -20,10 +20,10 @@ export interface MultilineInputProps {
|
|
|
20
20
|
pasteThreshold?: number;
|
|
21
21
|
/**
|
|
22
22
|
* Custom formatter for the placeholder display text.
|
|
23
|
-
* Receives the
|
|
24
|
-
* Default: (
|
|
23
|
+
* Receives the display number (1-based) and should return the display string.
|
|
24
|
+
* Default: (n) => `[Paste text #${n}]`
|
|
25
25
|
*/
|
|
26
|
-
formatPastePlaceholder?: (
|
|
26
|
+
formatPastePlaceholder?: (displayNumber: number) => string;
|
|
27
27
|
images?: ImageRef[];
|
|
28
28
|
onImagesChange?: (images: ImageRef[]) => void;
|
|
29
29
|
onPasteError?: (reason: PasteErrorReason) => void;
|
|
@@ -51,10 +51,10 @@ export interface MultilineInputCoreProps {
|
|
|
51
51
|
pasteThreshold?: number;
|
|
52
52
|
/**
|
|
53
53
|
* Custom formatter for the placeholder display text.
|
|
54
|
-
* Receives the
|
|
55
|
-
* Default: (
|
|
54
|
+
* Receives the display number (1-based) and should return the display string.
|
|
55
|
+
* Default: (n) => `[Paste text #${n}]`
|
|
56
56
|
*/
|
|
57
|
-
formatPastePlaceholder?: (
|
|
57
|
+
formatPastePlaceholder?: (displayNumber: number) => string;
|
|
58
58
|
images?: ImageRef[];
|
|
59
59
|
onImagesChange?: (images: ImageRef[]) => void;
|
|
60
60
|
}
|
|
@@ -7,15 +7,6 @@ import { handleKey } from './KeyHandler.js';
|
|
|
7
7
|
import { TextRenderer } from './TextRenderer.js';
|
|
8
8
|
import { useClipboardPaste } from './useClipboardPaste.js';
|
|
9
9
|
import { log } from '../../utils/logger.js';
|
|
10
|
-
function imagesToRecord(images) {
|
|
11
|
-
if (!images || images.length === 0)
|
|
12
|
-
return {};
|
|
13
|
-
const record = {};
|
|
14
|
-
for (const img of images) {
|
|
15
|
-
record[img.id] = img;
|
|
16
|
-
}
|
|
17
|
-
return record;
|
|
18
|
-
}
|
|
19
10
|
export const MultilineInputCore = ({ value, onChange, placeholder, showCursor = true, width = 80, onCursorChange, cursorOverride, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, }) => {
|
|
20
11
|
const textInput = useTextInput({ initialValue: value ?? '', undoDebounceMs, pasteThreshold, formatPastePlaceholder });
|
|
21
12
|
const isSyncingFromProps = useRef(false);
|
|
@@ -67,7 +58,7 @@ export const MultilineInputCore = ({ value, onChange, placeholder, showCursor =
|
|
|
67
58
|
if (showPlaceholder) {
|
|
68
59
|
return _jsx("div", { style: { opacity: 0.5 }, children: placeholder });
|
|
69
60
|
}
|
|
70
|
-
return (_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: width, showCursor: showCursor,
|
|
61
|
+
return (_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: width, showCursor: showCursor, blockState: textInput.blockState }));
|
|
71
62
|
};
|
|
72
63
|
export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCursor = true, width, isActive = true, onCursorChange, cursorOverride, onBoundaryArrow, undoDebounceMs, pasteThreshold, formatPastePlaceholder, images, onImagesChange, onPasteError, enableImagePaste = false, maxImageSizeBytes, maxImageCount, acceptedMimeTypes, }) => {
|
|
73
64
|
const terminalWidth = useTerminalWidth(width);
|
|
@@ -228,5 +219,5 @@ export const MultilineInput = ({ value, onChange, onSubmit, placeholder, showCur
|
|
|
228
219
|
if (showPlaceholder && !isPasting) {
|
|
229
220
|
return (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: placeholder }) }));
|
|
230
221
|
}
|
|
231
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: terminalWidth, showCursor: showCursor,
|
|
222
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(TextRenderer, { buffer: textInput.buffer, cursor: textInput.cursor, width: terminalWidth, showCursor: showCursor, blockState: textInput.blockState }), isPasting && (_jsx(Box, { children: _jsx(Text, { dimColor: true, children: "Reading clipboard..." }) }))] }));
|
|
232
223
|
};
|
|
@@ -33,26 +33,6 @@ export interface WrapResult {
|
|
|
33
33
|
/** Column in that visual row where cursor appears */
|
|
34
34
|
cursorVisualCol: number;
|
|
35
35
|
}
|
|
36
|
-
/**
|
|
37
|
-
* Information about a paste placeholder: its identifier, original text, and display label.
|
|
38
|
-
*/
|
|
39
|
-
export interface PlaceholderInfo {
|
|
40
|
-
/** Unique placeholder identifier */
|
|
41
|
-
id: number;
|
|
42
|
-
/** The original pasted text (may contain newlines) */
|
|
43
|
-
originalText: string;
|
|
44
|
-
/** The display label shown in place of the original text (e.g., "[Paste text #1]") */
|
|
45
|
-
displayText: string;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Registry state for paste placeholders.
|
|
49
|
-
*/
|
|
50
|
-
export interface PlaceholderState {
|
|
51
|
-
/** Map of placeholder ID to info */
|
|
52
|
-
placeholders: Map<number, PlaceholderInfo>;
|
|
53
|
-
/** Next auto-incrementing ID */
|
|
54
|
-
nextId: number;
|
|
55
|
-
}
|
|
56
36
|
/**
|
|
57
37
|
* Keyboard key state (mirrors Ink's Key interface)
|
|
58
38
|
* Defined locally to avoid ESM/CJS import issues with Ink
|
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
import type { Buffer, Cursor, Direction
|
|
1
|
+
import type { Buffer, Cursor, Direction } from './types.js';
|
|
2
2
|
import type { ImageRef } from './ImageTypes.js';
|
|
3
|
+
import type { BlockState } from './BlockTypes.js';
|
|
3
4
|
export interface UseTextInputProps {
|
|
4
5
|
initialValue?: string;
|
|
5
6
|
width?: number;
|
|
6
7
|
historyLimit?: number;
|
|
7
8
|
undoDebounceMs?: number;
|
|
8
|
-
/**
|
|
9
|
-
* When set, pasted text exceeding this character count is replaced
|
|
10
|
-
* with a placeholder for cleaner display.
|
|
11
|
-
*/
|
|
12
9
|
pasteThreshold?: number;
|
|
13
|
-
|
|
14
|
-
* Custom formatter for placeholder display text.
|
|
15
|
-
* Default: (id) => `[Paste text #${id}]`
|
|
16
|
-
*/
|
|
17
|
-
formatPastePlaceholder?: (id: number) => string;
|
|
10
|
+
formatPastePlaceholder?: (displayNumber: number) => string;
|
|
18
11
|
}
|
|
19
12
|
export interface UseTextInputResult {
|
|
20
13
|
value: string;
|
|
@@ -31,7 +24,7 @@ export interface UseTextInputResult {
|
|
|
31
24
|
setText: (text: string) => void;
|
|
32
25
|
cursorOffset: number;
|
|
33
26
|
setCursorOffset: (offset: number) => void;
|
|
34
|
-
|
|
27
|
+
blockState: BlockState;
|
|
35
28
|
insertImage: (imageRef: ImageRef) => void;
|
|
36
29
|
images: ImageRef[];
|
|
37
30
|
getImages: () => ImageRef[];
|
|
@@ -1,10 +1,9 @@
|
|
|
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,
|
|
3
|
-
import {
|
|
4
|
-
import { createSentinel, parseSentinels } from './ImageSentinel.js';
|
|
2
|
+
import { createBuffer, insertText as bufferInsertText, deleteChar as bufferDeleteChar, deleteCharForward as bufferDeleteCharForward, insertNewLine as bufferInsertNewLine, moveCursor as bufferMoveCursor, } from './TextBuffer.js';
|
|
3
|
+
import { createBlockState, createPasteBlockEntry, createImageBlockEntry, removeBlock, getValue, getValueCursorOffset, getCursorFromValueOffset, } from './BlockRegistry.js';
|
|
5
4
|
import { findAtomicBlockBefore, findAtomicBlockAfter } from './AtomicBlocks.js';
|
|
6
5
|
import { log } from '../../utils/logger.js';
|
|
7
|
-
const defaultFormatPlaceholder = (
|
|
6
|
+
const defaultFormatPlaceholder = (displayNumber) => `[Paste text #${displayNumber}]`;
|
|
8
7
|
export function useTextInput({ initialValue = '', width, historyLimit = 100, undoDebounceMs = 200, pasteThreshold, formatPastePlaceholder = defaultFormatPlaceholder, } = {}) {
|
|
9
8
|
const [buffer, setBuffer] = useState(() => createBuffer(initialValue));
|
|
10
9
|
const [cursor, setCursor] = useState(() => {
|
|
@@ -14,9 +13,7 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
14
13
|
column: lines[lines.length - 1].length,
|
|
15
14
|
};
|
|
16
15
|
});
|
|
17
|
-
const [
|
|
18
|
-
const [images, setImages] = useState({});
|
|
19
|
-
const nextDisplayNumberRef = useRef(1);
|
|
16
|
+
const [blockState, setBlockState] = useState(() => createBlockState());
|
|
20
17
|
const [undoStack, setUndoStack] = useState([]);
|
|
21
18
|
const [redoStack, setRedoStack] = useState([]);
|
|
22
19
|
const pendingInsertBatchRef = useRef({});
|
|
@@ -56,13 +53,12 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
56
53
|
pendingInsertBatchRef.current.startState = {
|
|
57
54
|
buffer: currentBuffer,
|
|
58
55
|
cursor: currentCursor,
|
|
59
|
-
|
|
60
|
-
images,
|
|
56
|
+
blockState,
|
|
61
57
|
};
|
|
62
58
|
setRedoStack([]);
|
|
63
59
|
}
|
|
64
60
|
schedulePendingInsertCommit();
|
|
65
|
-
}, [schedulePendingInsertCommit,
|
|
61
|
+
}, [schedulePendingInsertCommit, blockState]);
|
|
66
62
|
const flushPendingInsertBatch = useCallback(() => {
|
|
67
63
|
commitPendingInsertBatch();
|
|
68
64
|
}, [commitPendingInsertBatch]);
|
|
@@ -70,11 +66,10 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
70
66
|
appendUndoState({
|
|
71
67
|
buffer: currentBuffer,
|
|
72
68
|
cursor: currentCursor,
|
|
73
|
-
|
|
74
|
-
images,
|
|
69
|
+
blockState,
|
|
75
70
|
});
|
|
76
71
|
setRedoStack([]);
|
|
77
|
-
}, [appendUndoState,
|
|
72
|
+
}, [appendUndoState, blockState]);
|
|
78
73
|
useEffect(() => {
|
|
79
74
|
return () => {
|
|
80
75
|
clearPendingInsertTimer();
|
|
@@ -92,16 +87,16 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
92
87
|
}
|
|
93
88
|
}, [buffer, cursor, flushPendingInsertBatch, pushToHistory]);
|
|
94
89
|
const insert = useCallback((char) => {
|
|
95
|
-
log(`[INSERT] char="${char.replace(/[\x00-\x1F\x7F
|
|
90
|
+
log(`[INSERT] char="${char.replace(/[\x00-\x1F\x7F-\uFFFF]/g, c => `\\x${c.charCodeAt(0).toString(16)}`)}" len=${char.length} cursor={line:${cursor.line},col:${cursor.column}} linesBefore=${buffer.lines.length}`);
|
|
96
91
|
const normalized = char.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\x00/g, '');
|
|
97
92
|
if (normalized.length === 0)
|
|
98
93
|
return;
|
|
99
|
-
// Check if this is a paste exceeding the threshold
|
|
100
94
|
if (pasteThreshold !== undefined && pasteThreshold > 0 && normalized.length > pasteThreshold) {
|
|
101
95
|
flushPendingInsertBatch();
|
|
102
96
|
pushToHistory(buffer, cursor);
|
|
103
|
-
const
|
|
104
|
-
|
|
97
|
+
const displayText = formatPastePlaceholder(blockState.nextPasteNumber);
|
|
98
|
+
const { marker, state: newBlockState } = createPasteBlockEntry(blockState, normalized, displayText);
|
|
99
|
+
setBlockState(newBlockState);
|
|
105
100
|
const result = bufferInsertText(buffer, cursor, marker);
|
|
106
101
|
setBuffer(result.buffer);
|
|
107
102
|
setCursor(result.cursor);
|
|
@@ -120,70 +115,61 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
120
115
|
const result = bufferInsertText(buffer, cursor, normalized);
|
|
121
116
|
setBuffer(result.buffer);
|
|
122
117
|
setCursor(result.cursor);
|
|
123
|
-
}, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs, pasteThreshold,
|
|
118
|
+
}, [beginOrRefreshInsertBatch, buffer, cursor, flushPendingInsertBatch, pushToHistory, undoDebounceMs, pasteThreshold, blockState, formatPastePlaceholder]);
|
|
124
119
|
const cleanupBlockRegistry = useCallback((block) => {
|
|
125
120
|
if (!block)
|
|
126
121
|
return;
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
else if (block.kind === 'sentinel' && images[block.id]) {
|
|
131
|
-
const next = { ...images };
|
|
132
|
-
delete next[block.id];
|
|
133
|
-
setImages(next);
|
|
134
|
-
}
|
|
135
|
-
}, [images]);
|
|
122
|
+
setBlockState((prev) => removeBlock(prev, block.id));
|
|
123
|
+
}, []);
|
|
136
124
|
const deleteChar = useCallback(() => {
|
|
137
125
|
applyEdit(() => {
|
|
138
126
|
const line = buffer.lines[cursor.line];
|
|
139
|
-
cleanupBlockRegistry(findAtomicBlockBefore(line, cursor.column,
|
|
140
|
-
return bufferDeleteChar(buffer, cursor,
|
|
127
|
+
cleanupBlockRegistry(findAtomicBlockBefore(line, cursor.column, blockState.entries));
|
|
128
|
+
return bufferDeleteChar(buffer, cursor, blockState.entries);
|
|
141
129
|
});
|
|
142
|
-
}, [applyEdit, buffer, cursor,
|
|
130
|
+
}, [applyEdit, buffer, cursor, blockState, cleanupBlockRegistry]);
|
|
143
131
|
const deleteCharForward = useCallback(() => {
|
|
144
132
|
applyEdit(() => {
|
|
145
133
|
const line = buffer.lines[cursor.line];
|
|
146
|
-
cleanupBlockRegistry(findAtomicBlockAfter(line, cursor.column,
|
|
147
|
-
return bufferDeleteCharForward(buffer, cursor,
|
|
134
|
+
cleanupBlockRegistry(findAtomicBlockAfter(line, cursor.column, blockState.entries));
|
|
135
|
+
return bufferDeleteCharForward(buffer, cursor, blockState.entries);
|
|
148
136
|
});
|
|
149
|
-
}, [applyEdit, buffer, cursor,
|
|
137
|
+
}, [applyEdit, buffer, cursor, blockState, cleanupBlockRegistry]);
|
|
150
138
|
const newLine = useCallback(() => {
|
|
151
139
|
applyEdit(() => bufferInsertNewLine(buffer, cursor));
|
|
152
140
|
}, [applyEdit, buffer, cursor]);
|
|
153
141
|
const deleteAndNewLine = useCallback(() => {
|
|
154
142
|
applyEdit(() => {
|
|
155
|
-
const afterDelete = bufferDeleteChar(buffer, cursor,
|
|
143
|
+
const afterDelete = bufferDeleteChar(buffer, cursor, blockState.entries);
|
|
156
144
|
return bufferInsertNewLine(afterDelete.buffer, afterDelete.cursor);
|
|
157
145
|
});
|
|
158
|
-
}, [applyEdit, buffer, cursor,
|
|
146
|
+
}, [applyEdit, buffer, cursor, blockState]);
|
|
159
147
|
const moveCursor = useCallback((direction) => {
|
|
160
148
|
flushPendingInsertBatch();
|
|
161
|
-
const newCursor = bufferMoveCursor(buffer, cursor, direction, width,
|
|
149
|
+
const newCursor = bufferMoveCursor(buffer, cursor, direction, width, blockState.entries);
|
|
162
150
|
setCursor(newCursor);
|
|
163
|
-
}, [buffer, cursor, flushPendingInsertBatch, width,
|
|
151
|
+
}, [buffer, cursor, flushPendingInsertBatch, width, blockState]);
|
|
164
152
|
const undo = useCallback(() => {
|
|
165
153
|
const pendingStartState = pendingInsertBatchRef.current.startState;
|
|
166
154
|
if (pendingStartState) {
|
|
167
155
|
clearPendingInsertTimer();
|
|
168
156
|
pendingInsertBatchRef.current.startState = undefined;
|
|
169
|
-
setRedoStack((prev) => [...prev, { buffer, cursor,
|
|
157
|
+
setRedoStack((prev) => [...prev, { buffer, cursor, blockState }]);
|
|
170
158
|
setBuffer(pendingStartState.buffer);
|
|
171
159
|
setCursor(pendingStartState.cursor);
|
|
172
|
-
|
|
173
|
-
setImages(pendingStartState.images);
|
|
160
|
+
setBlockState(pendingStartState.blockState);
|
|
174
161
|
return;
|
|
175
162
|
}
|
|
176
163
|
if (undoStack.length === 0)
|
|
177
164
|
return;
|
|
178
165
|
const previousState = undoStack[undoStack.length - 1];
|
|
179
166
|
const newUndoStack = undoStack.slice(0, -1);
|
|
180
|
-
setRedoStack((prev) => [...prev, { buffer, cursor,
|
|
167
|
+
setRedoStack((prev) => [...prev, { buffer, cursor, blockState }]);
|
|
181
168
|
setBuffer(previousState.buffer);
|
|
182
169
|
setCursor(previousState.cursor);
|
|
183
|
-
|
|
184
|
-
setImages(previousState.images);
|
|
170
|
+
setBlockState(previousState.blockState);
|
|
185
171
|
setUndoStack(newUndoStack);
|
|
186
|
-
}, [buffer, clearPendingInsertTimer, cursor, undoStack,
|
|
172
|
+
}, [buffer, clearPendingInsertTimer, cursor, undoStack, blockState]);
|
|
187
173
|
const redo = useCallback(() => {
|
|
188
174
|
if (pendingInsertBatchRef.current.startState) {
|
|
189
175
|
return;
|
|
@@ -192,53 +178,70 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
192
178
|
return;
|
|
193
179
|
const nextState = redoStack[redoStack.length - 1];
|
|
194
180
|
const newRedoStack = redoStack.slice(0, -1);
|
|
195
|
-
setUndoStack((prev) => [...prev, { buffer, cursor,
|
|
181
|
+
setUndoStack((prev) => [...prev, { buffer, cursor, blockState }]);
|
|
196
182
|
setBuffer(nextState.buffer);
|
|
197
183
|
setCursor(nextState.cursor);
|
|
198
|
-
|
|
199
|
-
setImages(nextState.images);
|
|
184
|
+
setBlockState(nextState.blockState);
|
|
200
185
|
setRedoStack(newRedoStack);
|
|
201
|
-
}, [buffer, cursor, redoStack,
|
|
186
|
+
}, [buffer, cursor, redoStack, blockState]);
|
|
202
187
|
const setText = useCallback((text) => {
|
|
203
188
|
applyEdit(() => {
|
|
204
|
-
|
|
189
|
+
setBlockState(createBlockState());
|
|
205
190
|
const newBuffer = createBuffer(text);
|
|
206
191
|
const lines = text.split('\n');
|
|
207
192
|
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;
|
|
215
|
-
}
|
|
216
|
-
return next;
|
|
217
|
-
});
|
|
218
193
|
return { buffer: newBuffer, cursor: newCursor };
|
|
219
194
|
});
|
|
220
195
|
}, [applyEdit]);
|
|
221
196
|
const insertImage = useCallback((imageRef) => {
|
|
222
197
|
applyEdit(() => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
}
|
|
227
|
-
return bufferInsertText(buffer, cursor, createSentinel(imageRef.id, imageRef.displayNumber));
|
|
198
|
+
const { marker, state: newBlockState } = createImageBlockEntry(blockState, imageRef, imageRef.id);
|
|
199
|
+
setBlockState(newBlockState);
|
|
200
|
+
return bufferInsertText(buffer, cursor, marker);
|
|
228
201
|
});
|
|
229
|
-
}, [applyEdit, buffer, cursor]);
|
|
230
|
-
const value = useMemo(() => getValue(buffer.lines,
|
|
231
|
-
const cursorOffset = useMemo(() => getValueCursorOffset(buffer.lines, cursor,
|
|
232
|
-
const imagesList = useMemo(() =>
|
|
202
|
+
}, [applyEdit, buffer, cursor, blockState]);
|
|
203
|
+
const value = useMemo(() => getValue(buffer.lines, blockState.entries), [buffer.lines, blockState.entries]);
|
|
204
|
+
const cursorOffset = useMemo(() => getValueCursorOffset(buffer.lines, cursor, blockState.entries), [buffer.lines, cursor, blockState.entries]);
|
|
205
|
+
const imagesList = useMemo(() => {
|
|
206
|
+
const result = [];
|
|
207
|
+
for (const entry of blockState.entries.values()) {
|
|
208
|
+
if (entry.kind === 'image') {
|
|
209
|
+
result.push({
|
|
210
|
+
id: entry.id,
|
|
211
|
+
data: entry.data,
|
|
212
|
+
mimeType: entry.mimeType,
|
|
213
|
+
byteSize: entry.byteSize,
|
|
214
|
+
displayNumber: entry.displayNumber,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
219
|
+
}, [blockState.entries]);
|
|
233
220
|
const getImagesCallback = useCallback(() => {
|
|
234
221
|
return imagesList;
|
|
235
222
|
}, [imagesList]);
|
|
236
223
|
const setImagesCallback = useCallback((newImages) => {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
224
|
+
setBlockState((prev) => {
|
|
225
|
+
const newEntries = new Map(prev.entries);
|
|
226
|
+
for (const [id, entry] of prev.entries) {
|
|
227
|
+
if (entry.kind === 'image') {
|
|
228
|
+
newEntries.delete(id);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
let nextImageNumber = prev.nextImageNumber;
|
|
232
|
+
for (const img of newImages) {
|
|
233
|
+
nextImageNumber = Math.max(nextImageNumber, img.displayNumber + 1);
|
|
234
|
+
newEntries.set(img.id, {
|
|
235
|
+
kind: 'image',
|
|
236
|
+
id: img.id,
|
|
237
|
+
displayNumber: img.displayNumber,
|
|
238
|
+
data: img.data,
|
|
239
|
+
mimeType: img.mimeType,
|
|
240
|
+
byteSize: img.byteSize,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return { ...prev, entries: newEntries, nextImageNumber };
|
|
244
|
+
});
|
|
242
245
|
}, []);
|
|
243
246
|
return {
|
|
244
247
|
value,
|
|
@@ -256,9 +259,9 @@ export function useTextInput({ initialValue = '', width, historyLimit = 100, und
|
|
|
256
259
|
cursorOffset,
|
|
257
260
|
setCursorOffset: useCallback((offset) => {
|
|
258
261
|
flushPendingInsertBatch();
|
|
259
|
-
setCursor(getCursorFromValueOffset(buffer.lines, offset,
|
|
260
|
-
}, [buffer.lines, flushPendingInsertBatch,
|
|
261
|
-
|
|
262
|
+
setCursor(getCursorFromValueOffset(buffer.lines, offset, blockState.entries));
|
|
263
|
+
}, [buffer.lines, flushPendingInsertBatch, blockState.entries]),
|
|
264
|
+
blockState,
|
|
262
265
|
insertImage,
|
|
263
266
|
images: imagesList,
|
|
264
267
|
getImages: getImagesCallback,
|