ink-prompt 0.1.9 → 0.2.1
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 +118 -8
- package/dist/components/MultilineInput/ImageSentinel.d.ts +15 -0
- package/dist/components/MultilineInput/ImageSentinel.js +62 -0
- package/dist/components/MultilineInput/ImageTypes.d.ts +10 -0
- package/dist/components/MultilineInput/ImageTypes.js +2 -0
- package/dist/components/MultilineInput/ImageValidator.d.ts +7 -0
- package/dist/components/MultilineInput/ImageValidator.js +50 -0
- package/dist/components/MultilineInput/KeyHandler.d.ts +2 -1
- package/dist/components/MultilineInput/KeyHandler.js +5 -1
- package/dist/components/MultilineInput/Placeholder.d.ts +30 -0
- package/dist/components/MultilineInput/Placeholder.js +200 -0
- package/dist/components/MultilineInput/TextBuffer.d.ts +2 -0
- package/dist/components/MultilineInput/TextBuffer.js +140 -22
- package/dist/components/MultilineInput/TextRenderer.d.ts +7 -17
- package/dist/components/MultilineInput/TextRenderer.js +91 -74
- package/dist/components/MultilineInput/__tests__/ImageSentinel.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/ImageSentinel.test.js +154 -0
- package/dist/components/MultilineInput/__tests__/ImageValidator.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/ImageValidator.test.js +91 -0
- package/dist/components/MultilineInput/__tests__/KeyHandler_images.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/KeyHandler_images.test.js +48 -0
- package/dist/components/MultilineInput/__tests__/Placeholder.integration.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/Placeholder.integration.test.js +318 -0
- package/dist/components/MultilineInput/__tests__/Placeholder.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/Placeholder.test.js +235 -0
- package/dist/components/MultilineInput/__tests__/TextBuffer_images.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/TextBuffer_images.test.js +144 -0
- package/dist/components/MultilineInput/__tests__/TextRenderer_images.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/TextRenderer_images.test.js +72 -0
- package/dist/components/MultilineInput/__tests__/clipboard/clipboardReaders.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/clipboard/clipboardReaders.test.js +81 -0
- package/dist/components/MultilineInput/__tests__/integration_images.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/integration_images.test.js +35 -0
- package/dist/components/MultilineInput/__tests__/useClipboardPaste.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/useClipboardPaste.test.js +139 -0
- package/dist/components/MultilineInput/__tests__/useTextInput_images.test.d.ts +1 -0
- package/dist/components/MultilineInput/__tests__/useTextInput_images.test.js +110 -0
- package/dist/components/MultilineInput/clipboard/ClipboardReader.d.ts +12 -0
- package/dist/components/MultilineInput/clipboard/ClipboardReader.js +1 -0
- package/dist/components/MultilineInput/clipboard/LinuxWaylandClipboardReader.d.ts +13 -0
- package/dist/components/MultilineInput/clipboard/LinuxWaylandClipboardReader.js +48 -0
- package/dist/components/MultilineInput/clipboard/LinuxX11ClipboardReader.d.ts +13 -0
- package/dist/components/MultilineInput/clipboard/LinuxX11ClipboardReader.js +52 -0
- package/dist/components/MultilineInput/clipboard/MacOSClipboardReader.d.ts +13 -0
- package/dist/components/MultilineInput/clipboard/MacOSClipboardReader.js +56 -0
- package/dist/components/MultilineInput/clipboard/WindowsClipboardReader.d.ts +13 -0
- package/dist/components/MultilineInput/clipboard/WindowsClipboardReader.js +64 -0
- package/dist/components/MultilineInput/clipboard/index.d.ts +3 -0
- package/dist/components/MultilineInput/clipboard/index.js +22 -0
- package/dist/components/MultilineInput/index.d.ts +32 -100
- package/dist/components/MultilineInput/index.js +70 -47
- package/dist/components/MultilineInput/types.d.ts +20 -0
- package/dist/components/MultilineInput/useClipboardPaste.d.ts +22 -0
- package/dist/components/MultilineInput/useClipboardPaste.js +60 -0
- package/dist/components/MultilineInput/useTextInput.d.ts +18 -7
- package/dist/components/MultilineInput/useTextInput.js +224 -28
- package/dist/index.d.ts +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# ink-prompt
|
|
2
2
|
|
|
3
|
-
A React Ink component library
|
|
4
|
-
|
|
5
|
-
CLIs.
|
|
3
|
+
A React Ink component library for creating interactive CLI prompts. Provides
|
|
4
|
+
`MultilineInput` for collecting multi-line text in terminal applications.
|
|
6
5
|
|
|
7
6
|
## Installation
|
|
8
7
|
|
|
@@ -23,7 +22,6 @@ const App = () => {
|
|
|
23
22
|
<Text>Describe your change (press Enter to submit):</Text>
|
|
24
23
|
<MultilineInput
|
|
25
24
|
onSubmit={(value) => console.log(value)}
|
|
26
|
-
width={80}
|
|
27
25
|
/>
|
|
28
26
|
</Box>
|
|
29
27
|
);
|
|
@@ -32,12 +30,119 @@ const App = () => {
|
|
|
32
30
|
render(<App />);
|
|
33
31
|
```
|
|
34
32
|
|
|
33
|
+
### Props
|
|
34
|
+
|
|
35
|
+
| Prop | Type | Default | Description |
|
|
36
|
+
|------|------|---------|-------------|
|
|
37
|
+
| `value` | `string` | | External control of the text value (controlled mode) |
|
|
38
|
+
| `onChange` | `(value: string) => void` | | Called when text content changes |
|
|
39
|
+
| `onSubmit` | `(value: string) => void` | | Called when Enter is pressed (without trailing `\`) |
|
|
40
|
+
| `placeholder` | `string` | | Placeholder text shown when empty and cursor is hidden |
|
|
41
|
+
| `showCursor` | `boolean` | `true` | Whether to display the cursor |
|
|
42
|
+
| `width` | `number` | terminal width | Width for word wrapping (auto-resizes with terminal) |
|
|
43
|
+
| `isActive` | `boolean` | `true` | Whether the input accepts keyboard events |
|
|
44
|
+
| `onCursorChange` | `(offset: number) => void` | | Called when cursor position changes |
|
|
45
|
+
| `cursorOverride` | `number` | | Force cursor to a specific offset |
|
|
46
|
+
| `onBoundaryArrow` | `(dir) => void` | | Called when arrow key reaches a boundary |
|
|
47
|
+
| `undoDebounceMs` | `number` | `200` | Milliseconds of inactivity to commit undo batch (`0` = disable) |
|
|
48
|
+
| `pasteThreshold` | `number` | | Max paste length before text is replaced by a placeholder |
|
|
49
|
+
| `formatPastePlaceholder` | `(id: number) => string` | | Custom placeholder display format |
|
|
50
|
+
|
|
51
|
+
### Keyboard Controls
|
|
52
|
+
|
|
35
53
|
`MultilineInput` supports typical editing controls:
|
|
36
54
|
|
|
37
|
-
- Arrow keys for navigation
|
|
38
|
-
- `Ctrl+J` or typing `\` before Enter to add a newline
|
|
39
|
-
- `Ctrl+Z
|
|
40
|
-
-
|
|
55
|
+
- **Arrow keys** for navigation
|
|
56
|
+
- `Ctrl+J` or typing `\` before **Enter** to add a newline
|
|
57
|
+
- `Ctrl+Z` / `Ctrl+Y` for undo/redo
|
|
58
|
+
- `Ctrl+A` / `Ctrl+E` for jump to line start/end
|
|
59
|
+
- **Home** / **End** keys for line start/end
|
|
60
|
+
- `Ctrl+V` to paste text or images (when `enableImagePaste` is enabled)
|
|
61
|
+
- **Enter** submits the current buffer
|
|
62
|
+
- **Delete** for forward delete
|
|
63
|
+
|
|
64
|
+
### Paste Placeholders
|
|
65
|
+
|
|
66
|
+
When pasting large amounts of text, you can use `pasteThreshold` to automatically
|
|
67
|
+
replace the pasted content with a compact placeholder for cleaner display.
|
|
68
|
+
|
|
69
|
+
```tsx
|
|
70
|
+
<MultilineInput
|
|
71
|
+
onSubmit={(value) => console.log(value)}
|
|
72
|
+
pasteThreshold={200} // Text >200 chars becomes a placeholder
|
|
73
|
+
formatPastePlaceholder={(id) => `[Pasted block #${id}]`} // Optional formatter
|
|
74
|
+
/>
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**How it works:**
|
|
78
|
+
- Pasted text exceeding `pasteThreshold` is replaced with a placeholder (default: `[Paste text #N]`, customizable via `formatPastePlaceholder`)
|
|
79
|
+
- The full original text is preserved — `onChange` / `onSubmit` return the unmodified content
|
|
80
|
+
- Placeholders are **atomic**: backspace/delete removes the entire placeholder in one action
|
|
81
|
+
- Arrow keys skip over placeholders (cursor cannot land inside one)
|
|
82
|
+
- Undo/redo correctly tracks placeholder state
|
|
83
|
+
- Counter resets per component instance
|
|
84
|
+
|
|
85
|
+
## Image Paste
|
|
86
|
+
|
|
87
|
+
Image paste is opt-in through `enableImagePaste`. When enabled, `Ctrl+V`
|
|
88
|
+
reads the system clipboard. Text is inserted as usual; supported images are
|
|
89
|
+
inserted into the text buffer as placeholders such as `[Pasted Image #1]` and
|
|
90
|
+
returned separately through `onSubmit` or `onImagesChange`.
|
|
91
|
+
|
|
92
|
+
```tsx
|
|
93
|
+
import React, {useState} from 'react';
|
|
94
|
+
import {render, Box, Text} from 'ink';
|
|
95
|
+
import {MultilineInput, type ImageRef, type PasteErrorReason} from 'ink-prompt';
|
|
96
|
+
|
|
97
|
+
const App = () => {
|
|
98
|
+
const [images, setImages] = useState<ImageRef[]>([]);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<Box flexDirection="column">
|
|
102
|
+
<Text>Prompt:</Text>
|
|
103
|
+
<MultilineInput
|
|
104
|
+
enableImagePaste
|
|
105
|
+
maxImageCount={5}
|
|
106
|
+
maxImageSizeBytes={5 * 1024 * 1024}
|
|
107
|
+
acceptedMimeTypes={['image/png', 'image/jpeg', 'image/webp', 'image/gif']}
|
|
108
|
+
images={images}
|
|
109
|
+
onImagesChange={setImages}
|
|
110
|
+
onPasteError={(reason: PasteErrorReason) => {
|
|
111
|
+
console.error(`Paste failed: ${reason}`);
|
|
112
|
+
}}
|
|
113
|
+
onSubmit={(value, submittedImages) => {
|
|
114
|
+
console.log(value);
|
|
115
|
+
console.log(submittedImages);
|
|
116
|
+
}}
|
|
117
|
+
width={80}
|
|
118
|
+
/>
|
|
119
|
+
</Box>
|
|
120
|
+
);
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
render(<App />);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Supported image types are detected from image bytes: PNG, JPEG, WebP, and GIF.
|
|
127
|
+
Clipboard access is platform-specific:
|
|
128
|
+
|
|
129
|
+
- macOS: `osascript`
|
|
130
|
+
- Linux X11: `xclip`
|
|
131
|
+
- Linux Wayland: `wl-paste`
|
|
132
|
+
- Windows: PowerShell and `System.Windows.Forms.Clipboard`
|
|
133
|
+
|
|
134
|
+
Related props:
|
|
135
|
+
|
|
136
|
+
- `enableImagePaste?: boolean` - enables image-aware `Ctrl+V` handling.
|
|
137
|
+
- `images?: ImageRef[]` and `onImagesChange?: (images: ImageRef[]) => void` -
|
|
138
|
+
controlled image state for pasted images.
|
|
139
|
+
- `onSubmit?: (value: string, images?: ImageRef[]) => void` - receives the text
|
|
140
|
+
buffer and current images.
|
|
141
|
+
- `onPasteError?: (reason: PasteErrorReason) => void` - receives paste and
|
|
142
|
+
validation failures.
|
|
143
|
+
- `maxImageSizeBytes?: number` - defaults to 10 MiB.
|
|
144
|
+
- `maxImageCount?: number` - defaults to 10.
|
|
145
|
+
- `acceptedMimeTypes?: string[]` - restricts accepted image MIME types.
|
|
41
146
|
|
|
42
147
|
## Development
|
|
43
148
|
|
|
@@ -53,6 +158,11 @@ npm run dev
|
|
|
53
158
|
|
|
54
159
|
# Type check
|
|
55
160
|
npm run type-check
|
|
161
|
+
|
|
162
|
+
# Run tests
|
|
163
|
+
npm test
|
|
164
|
+
npm run test:watch
|
|
165
|
+
npm run test:ui
|
|
56
166
|
```
|
|
57
167
|
|
|
58
168
|
## License
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function generateImageId(): string;
|
|
2
|
+
export declare function createSentinel(id: string, displayNumber: number): string;
|
|
3
|
+
export interface SentinelInfo {
|
|
4
|
+
id: string;
|
|
5
|
+
displayNumber: number;
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
}
|
|
9
|
+
export declare function parseSentinels(text: string): SentinelInfo[];
|
|
10
|
+
export declare function findSentinelAt(text: string, offset: number): SentinelInfo | null;
|
|
11
|
+
export declare function isInsideSentinel(text: string, offset: number): boolean;
|
|
12
|
+
export declare function removeSentinel(text: string, offset: number): string;
|
|
13
|
+
export declare function getPlaceholderText(displayNumber: number): string;
|
|
14
|
+
export declare function getPlaceholderVisualWidth(displayNumber: number): number;
|
|
15
|
+
export declare function getSentinelVisualWidthFromText(text: string, offset: number): number | null;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { SENTINEL_OPEN, SENTINEL_CLOSE } from './ImageTypes.js';
|
|
2
|
+
export function generateImageId() {
|
|
3
|
+
return Math.random().toString(36).substring(2, 10) + Math.random().toString(36).substring(2, 6);
|
|
4
|
+
}
|
|
5
|
+
export function createSentinel(id, displayNumber) {
|
|
6
|
+
return `${SENTINEL_OPEN}${id}:${displayNumber}${SENTINEL_CLOSE}`;
|
|
7
|
+
}
|
|
8
|
+
export function parseSentinels(text) {
|
|
9
|
+
const result = [];
|
|
10
|
+
let i = 0;
|
|
11
|
+
while (i < text.length) {
|
|
12
|
+
const openIdx = text.indexOf(SENTINEL_OPEN, i);
|
|
13
|
+
if (openIdx === -1)
|
|
14
|
+
break;
|
|
15
|
+
const closeIdx = text.indexOf(SENTINEL_CLOSE, openIdx + 1);
|
|
16
|
+
if (closeIdx === -1)
|
|
17
|
+
break;
|
|
18
|
+
const raw = text.substring(openIdx + 1, closeIdx);
|
|
19
|
+
const colonIdx = raw.lastIndexOf(':');
|
|
20
|
+
const id = colonIdx >= 0 ? raw.substring(0, colonIdx) : raw;
|
|
21
|
+
const displayNumber = colonIdx >= 0 ? parseInt(raw.substring(colonIdx + 1), 10) || 1 : 1;
|
|
22
|
+
result.push({ id, displayNumber, start: openIdx, end: closeIdx + 1 });
|
|
23
|
+
i = closeIdx + 1;
|
|
24
|
+
}
|
|
25
|
+
return result;
|
|
26
|
+
}
|
|
27
|
+
export function findSentinelAt(text, offset) {
|
|
28
|
+
const sentinels = parseSentinels(text);
|
|
29
|
+
for (const s of sentinels) {
|
|
30
|
+
if (offset >= s.start && offset <= s.end) {
|
|
31
|
+
return s;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
export function isInsideSentinel(text, offset) {
|
|
37
|
+
const sentinels = parseSentinels(text);
|
|
38
|
+
for (const s of sentinels) {
|
|
39
|
+
if (offset >= s.start && offset < s.end) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
export function removeSentinel(text, offset) {
|
|
46
|
+
const sentinel = findSentinelAt(text, offset);
|
|
47
|
+
if (!sentinel)
|
|
48
|
+
return text;
|
|
49
|
+
return text.slice(0, sentinel.start) + text.slice(sentinel.end);
|
|
50
|
+
}
|
|
51
|
+
export function getPlaceholderText(displayNumber) {
|
|
52
|
+
return `[Pasted Image #${displayNumber}]`;
|
|
53
|
+
}
|
|
54
|
+
export function getPlaceholderVisualWidth(displayNumber) {
|
|
55
|
+
return getPlaceholderText(displayNumber).length;
|
|
56
|
+
}
|
|
57
|
+
export function getSentinelVisualWidthFromText(text, offset) {
|
|
58
|
+
const s = findSentinelAt(text, offset);
|
|
59
|
+
if (!s)
|
|
60
|
+
return null;
|
|
61
|
+
return getPlaceholderVisualWidth(s.displayNumber);
|
|
62
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface ImageRef {
|
|
2
|
+
id: string;
|
|
3
|
+
data: string;
|
|
4
|
+
mimeType: string;
|
|
5
|
+
byteSize: number;
|
|
6
|
+
displayNumber: number;
|
|
7
|
+
}
|
|
8
|
+
export type PasteErrorReason = 'clipboard-timeout' | 'clipboard-read-error' | 'clipboard-unsupported-type' | 'image-too-large' | 'too-many-images' | 'clipboard-empty';
|
|
9
|
+
export declare const SENTINEL_OPEN = "\uE000";
|
|
10
|
+
export declare const SENTINEL_CLOSE = "\uE001";
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ImageRef } from './ImageTypes.js';
|
|
2
|
+
export interface ValidateImageOptions {
|
|
3
|
+
maxImageSizeBytes?: number;
|
|
4
|
+
maxImageCount?: number;
|
|
5
|
+
acceptedMimeTypes?: string[];
|
|
6
|
+
}
|
|
7
|
+
export declare function validateImage(bytes: Buffer, existingImages: ImageRef[], options: ValidateImageOptions): ImageRef;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { generateImageId } from './ImageSentinel.js';
|
|
2
|
+
const MAGIC_BYTES = [
|
|
3
|
+
{ mimeType: 'image/png', magic: [0x89, 0x50, 0x4e, 0x47] },
|
|
4
|
+
{ mimeType: 'image/jpeg', magic: [0xff, 0xd8, 0xff] },
|
|
5
|
+
{ mimeType: 'image/webp', magic: [0x52, 0x49, 0x46, 0x46] },
|
|
6
|
+
{ mimeType: 'image/gif', magic: [0x47, 0x49, 0x46, 0x38] },
|
|
7
|
+
];
|
|
8
|
+
function detectMimeType(bytes) {
|
|
9
|
+
for (const { mimeType, magic } of MAGIC_BYTES) {
|
|
10
|
+
if (magic.every((b, i) => bytes[i] === b)) {
|
|
11
|
+
// WebP requires additional check for WEBP header at offset 8
|
|
12
|
+
if (mimeType === 'image/webp') {
|
|
13
|
+
if (bytes.length >= 12 &&
|
|
14
|
+
bytes[8] === 0x57 && bytes[9] === 0x45 &&
|
|
15
|
+
bytes[10] === 0x42 && bytes[11] === 0x50) {
|
|
16
|
+
return mimeType;
|
|
17
|
+
}
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
return mimeType;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
export function validateImage(bytes, existingImages, options) {
|
|
26
|
+
const maxSize = options.maxImageSizeBytes ?? 10 * 1024 * 1024;
|
|
27
|
+
const maxCount = options.maxImageCount ?? 10;
|
|
28
|
+
if (bytes.length > maxSize) {
|
|
29
|
+
throw new Error('image-too-large');
|
|
30
|
+
}
|
|
31
|
+
if (existingImages.length >= maxCount) {
|
|
32
|
+
throw new Error('too-many-images');
|
|
33
|
+
}
|
|
34
|
+
const mimeType = detectMimeType(bytes);
|
|
35
|
+
if (!mimeType) {
|
|
36
|
+
throw new Error('clipboard-unsupported-type');
|
|
37
|
+
}
|
|
38
|
+
if (options.acceptedMimeTypes && !options.acceptedMimeTypes.includes(mimeType)) {
|
|
39
|
+
throw new Error('clipboard-unsupported-type');
|
|
40
|
+
}
|
|
41
|
+
const maxDisplayNumber = existingImages.reduce((max, img) => Math.max(max, img.displayNumber), 0);
|
|
42
|
+
const displayNumber = maxDisplayNumber + 1;
|
|
43
|
+
return {
|
|
44
|
+
id: generateImageId(),
|
|
45
|
+
data: bytes.toString('base64'),
|
|
46
|
+
mimeType,
|
|
47
|
+
byteSize: bytes.length,
|
|
48
|
+
displayNumber,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type Key, type Buffer, type Cursor } from './types.js';
|
|
2
2
|
import { type UseTextInputResult } from './useTextInput.js';
|
|
3
|
-
export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor' | 'cursorOffset' | 'setCursorOffset'> {
|
|
3
|
+
export interface KeyHandlerActions extends Omit<UseTextInputResult, 'value' | 'cursor' | 'cursorOffset' | 'setCursorOffset' | 'buffer' | 'placeholderState' | 'insertImage' | 'images' | 'getImages' | 'setImages'> {
|
|
4
4
|
submit: () => void;
|
|
5
5
|
onBoundaryArrow?: (direction: 'up' | 'down' | 'left' | 'right') => void;
|
|
6
|
+
paste?: () => void;
|
|
6
7
|
}
|
|
7
8
|
/**
|
|
8
9
|
* Handles keyboard input and maps it to text input actions.
|
|
@@ -192,8 +192,12 @@ export function handleKey(key, input, buffer, actions, cursor, rawInput, width)
|
|
|
192
192
|
actions.moveCursor('lineEnd');
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
|
-
// History
|
|
195
|
+
// Paste / History
|
|
196
196
|
if (key.ctrl) {
|
|
197
|
+
if (input === 'v' && actions.paste) {
|
|
198
|
+
actions.paste();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
197
201
|
if (input === 'z') {
|
|
198
202
|
actions.undo();
|
|
199
203
|
return;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PlaceholderInfo, PlaceholderState } from './types.js';
|
|
2
|
+
export declare const MARKER_REGEX: RegExp;
|
|
3
|
+
export declare function createMarker(id: number): string;
|
|
4
|
+
export declare function createPlaceholderState(): PlaceholderState;
|
|
5
|
+
export declare function addPlaceholder(state: PlaceholderState, originalText: string, displayText: string): {
|
|
6
|
+
id: number;
|
|
7
|
+
marker: string;
|
|
8
|
+
state: PlaceholderState;
|
|
9
|
+
};
|
|
10
|
+
export declare function removePlaceholder(state: PlaceholderState, id: number): PlaceholderState;
|
|
11
|
+
export declare function getDisplayLine(line: string, placeholders: Map<number, PlaceholderInfo>): string;
|
|
12
|
+
export declare function getValue(lines: string[], placeholders: Map<number, PlaceholderInfo>): string;
|
|
13
|
+
export interface MarkerRange {
|
|
14
|
+
id: number;
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
}
|
|
18
|
+
export declare function findPlaceholderAt(line: string, column: number): MarkerRange | null;
|
|
19
|
+
export declare function findPlaceholderAfter(line: string, column: number): MarkerRange | null;
|
|
20
|
+
export declare function findPlaceholderBefore(line: string, column: number): MarkerRange | null;
|
|
21
|
+
export declare function bufferColToDisplayCol(line: string, column: number, placeholders: Map<number, PlaceholderInfo>): number;
|
|
22
|
+
export declare function displayColToBufferCol(line: string, displayColumn: number, placeholders: Map<number, PlaceholderInfo>): number;
|
|
23
|
+
export declare function getValueCursorOffset(lines: string[], cursor: {
|
|
24
|
+
line: number;
|
|
25
|
+
column: number;
|
|
26
|
+
}, placeholders: Map<number, PlaceholderInfo>): number;
|
|
27
|
+
export declare function getCursorFromValueOffset(lines: string[], offset: number, placeholders: Map<number, PlaceholderInfo>): {
|
|
28
|
+
line: number;
|
|
29
|
+
column: number;
|
|
30
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
export const MARKER_REGEX = /\x00P(\d+)\x00/g;
|
|
2
|
+
export function createMarker(id) {
|
|
3
|
+
return `\x00P${id}\x00`;
|
|
4
|
+
}
|
|
5
|
+
export function createPlaceholderState() {
|
|
6
|
+
return { placeholders: new Map(), nextId: 0 };
|
|
7
|
+
}
|
|
8
|
+
export function addPlaceholder(state, originalText, displayText) {
|
|
9
|
+
const id = state.nextId;
|
|
10
|
+
const marker = createMarker(id);
|
|
11
|
+
const newPlaceholders = new Map(state.placeholders);
|
|
12
|
+
newPlaceholders.set(id, { id, originalText, displayText });
|
|
13
|
+
return { id, marker, state: { placeholders: newPlaceholders, nextId: id + 1 } };
|
|
14
|
+
}
|
|
15
|
+
export function removePlaceholder(state, id) {
|
|
16
|
+
const newPlaceholders = new Map(state.placeholders);
|
|
17
|
+
newPlaceholders.delete(id);
|
|
18
|
+
return { ...state, placeholders: newPlaceholders };
|
|
19
|
+
}
|
|
20
|
+
export function getDisplayLine(line, placeholders) {
|
|
21
|
+
return line.replace(MARKER_REGEX, (_, idStr) => {
|
|
22
|
+
const info = placeholders.get(Number(idStr));
|
|
23
|
+
return info ? info.displayText : '';
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
export function getValue(lines, placeholders) {
|
|
27
|
+
const text = lines.join('\n');
|
|
28
|
+
return text.replace(MARKER_REGEX, (_, idStr) => {
|
|
29
|
+
const info = placeholders.get(Number(idStr));
|
|
30
|
+
return info ? info.originalText : '';
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export function findPlaceholderAt(line, column) {
|
|
34
|
+
MARKER_REGEX.lastIndex = 0;
|
|
35
|
+
let match;
|
|
36
|
+
while ((match = MARKER_REGEX.exec(line)) !== null) {
|
|
37
|
+
const start = match.index;
|
|
38
|
+
const end = start + match[0].length;
|
|
39
|
+
if (column > start && column < end) {
|
|
40
|
+
return { id: Number(match[1]), start, end };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
export function findPlaceholderAfter(line, column) {
|
|
46
|
+
MARKER_REGEX.lastIndex = 0;
|
|
47
|
+
let match;
|
|
48
|
+
while ((match = MARKER_REGEX.exec(line)) !== null) {
|
|
49
|
+
if (match.index === column) {
|
|
50
|
+
return { id: Number(match[1]), start: match.index, end: match.index + match[0].length };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
export function findPlaceholderBefore(line, column) {
|
|
56
|
+
MARKER_REGEX.lastIndex = 0;
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = MARKER_REGEX.exec(line)) !== null) {
|
|
59
|
+
const end = match.index + match[0].length;
|
|
60
|
+
if (end === column) {
|
|
61
|
+
return { id: Number(match[1]), start: match.index, end };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
export function bufferColToDisplayCol(line, column, placeholders) {
|
|
67
|
+
if (!placeholders || placeholders.size === 0)
|
|
68
|
+
return column;
|
|
69
|
+
let displayCol = 0;
|
|
70
|
+
let lastEnd = 0;
|
|
71
|
+
MARKER_REGEX.lastIndex = 0;
|
|
72
|
+
let match;
|
|
73
|
+
while ((match = MARKER_REGEX.exec(line)) !== null) {
|
|
74
|
+
const markerStart = match.index;
|
|
75
|
+
const markerEnd = markerStart + match[0].length;
|
|
76
|
+
if (column <= markerStart) {
|
|
77
|
+
return displayCol + (column - lastEnd);
|
|
78
|
+
}
|
|
79
|
+
displayCol += markerStart - lastEnd;
|
|
80
|
+
const info = placeholders.get(Number(match[1]));
|
|
81
|
+
const displayLen = info ? info.displayText.length : 0;
|
|
82
|
+
if (column <= markerEnd) {
|
|
83
|
+
return displayCol + displayLen;
|
|
84
|
+
}
|
|
85
|
+
displayCol += displayLen;
|
|
86
|
+
lastEnd = markerEnd;
|
|
87
|
+
}
|
|
88
|
+
return displayCol + (column - lastEnd);
|
|
89
|
+
}
|
|
90
|
+
export function displayColToBufferCol(line, displayColumn, placeholders) {
|
|
91
|
+
if (!placeholders || placeholders.size === 0)
|
|
92
|
+
return displayColumn;
|
|
93
|
+
let bufPos = 0;
|
|
94
|
+
let dispPos = 0;
|
|
95
|
+
MARKER_REGEX.lastIndex = 0;
|
|
96
|
+
let match;
|
|
97
|
+
while ((match = MARKER_REGEX.exec(line)) !== null) {
|
|
98
|
+
const markerStart = match.index;
|
|
99
|
+
const markerEnd = markerStart + match[0].length;
|
|
100
|
+
const textLen = markerStart - bufPos;
|
|
101
|
+
if (displayColumn <= dispPos + textLen) {
|
|
102
|
+
return bufPos + (displayColumn - dispPos);
|
|
103
|
+
}
|
|
104
|
+
dispPos += textLen;
|
|
105
|
+
bufPos = markerStart;
|
|
106
|
+
const info = placeholders.get(Number(match[1]));
|
|
107
|
+
const displayLen = info ? info.displayText.length : 0;
|
|
108
|
+
if (displayColumn <= dispPos + displayLen) {
|
|
109
|
+
return markerEnd;
|
|
110
|
+
}
|
|
111
|
+
dispPos += displayLen;
|
|
112
|
+
bufPos = markerEnd;
|
|
113
|
+
}
|
|
114
|
+
return bufPos + (displayColumn - dispPos);
|
|
115
|
+
}
|
|
116
|
+
export function getValueCursorOffset(lines, cursor, placeholders) {
|
|
117
|
+
let offset = 0;
|
|
118
|
+
for (let i = 0; i < cursor.line; i++) {
|
|
119
|
+
offset += getExpandedLineLength(lines[i], placeholders) + 1;
|
|
120
|
+
}
|
|
121
|
+
const line = lines[cursor.line];
|
|
122
|
+
let bufPos = 0;
|
|
123
|
+
MARKER_REGEX.lastIndex = 0;
|
|
124
|
+
let match;
|
|
125
|
+
while ((match = MARKER_REGEX.exec(line)) !== null) {
|
|
126
|
+
const markerStart = match.index;
|
|
127
|
+
const markerEnd = markerStart + match[0].length;
|
|
128
|
+
if (cursor.column <= markerStart) {
|
|
129
|
+
offset += cursor.column - bufPos;
|
|
130
|
+
return offset;
|
|
131
|
+
}
|
|
132
|
+
offset += markerStart - bufPos;
|
|
133
|
+
const info = placeholders.get(Number(match[1]));
|
|
134
|
+
offset += info ? info.originalText.length : 0;
|
|
135
|
+
bufPos = markerEnd;
|
|
136
|
+
if (cursor.column <= markerEnd) {
|
|
137
|
+
return offset;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
offset += cursor.column - bufPos;
|
|
141
|
+
return offset;
|
|
142
|
+
}
|
|
143
|
+
export function getCursorFromValueOffset(lines, offset, placeholders) {
|
|
144
|
+
let currentOffset = 0;
|
|
145
|
+
const lineCount = lines.length;
|
|
146
|
+
for (let i = 0; i < lineCount; i++) {
|
|
147
|
+
const lineLen = getExpandedLineLength(lines[i], placeholders);
|
|
148
|
+
if (i === lineCount - 1) {
|
|
149
|
+
if (offset <= currentOffset + lineLen) {
|
|
150
|
+
const colInExpanded = offset - currentOffset;
|
|
151
|
+
return { line: i, column: valueOffsetToBufferColumn(lines[i], colInExpanded, placeholders) };
|
|
152
|
+
}
|
|
153
|
+
return { line: i, column: lines[i].length };
|
|
154
|
+
}
|
|
155
|
+
if (offset <= currentOffset + lineLen) {
|
|
156
|
+
const colInExpanded = offset - currentOffset;
|
|
157
|
+
return { line: i, column: valueOffsetToBufferColumn(lines[i], colInExpanded, placeholders) };
|
|
158
|
+
}
|
|
159
|
+
currentOffset += lineLen + 1;
|
|
160
|
+
}
|
|
161
|
+
const lastIdx = lines.length - 1;
|
|
162
|
+
return { line: lastIdx, column: lines[lastIdx].length };
|
|
163
|
+
}
|
|
164
|
+
function getExpandedLineLength(line, placeholders) {
|
|
165
|
+
let len = 0;
|
|
166
|
+
let lastEnd = 0;
|
|
167
|
+
MARKER_REGEX.lastIndex = 0;
|
|
168
|
+
let match;
|
|
169
|
+
while ((match = MARKER_REGEX.exec(line)) !== null) {
|
|
170
|
+
len += match.index - lastEnd;
|
|
171
|
+
const info = placeholders.get(Number(match[1]));
|
|
172
|
+
len += info ? info.originalText.length : 0;
|
|
173
|
+
lastEnd = match.index + match[0].length;
|
|
174
|
+
}
|
|
175
|
+
len += line.length - lastEnd;
|
|
176
|
+
return len;
|
|
177
|
+
}
|
|
178
|
+
function valueOffsetToBufferColumn(line, offsetInExpanded, placeholders) {
|
|
179
|
+
let bufPos = 0;
|
|
180
|
+
let expandedPos = 0;
|
|
181
|
+
MARKER_REGEX.lastIndex = 0;
|
|
182
|
+
let match;
|
|
183
|
+
while ((match = MARKER_REGEX.exec(line)) !== null) {
|
|
184
|
+
const markerStart = match.index;
|
|
185
|
+
const textLen = markerStart - bufPos;
|
|
186
|
+
if (offsetInExpanded <= expandedPos + textLen) {
|
|
187
|
+
return bufPos + (offsetInExpanded - expandedPos);
|
|
188
|
+
}
|
|
189
|
+
expandedPos += textLen;
|
|
190
|
+
bufPos = markerStart;
|
|
191
|
+
const info = placeholders.get(Number(match[1]));
|
|
192
|
+
const originalLen = info ? info.originalText.length : 0;
|
|
193
|
+
if (offsetInExpanded <= expandedPos + originalLen) {
|
|
194
|
+
return markerStart + match[0].length;
|
|
195
|
+
}
|
|
196
|
+
expandedPos += originalLen;
|
|
197
|
+
bufPos = markerStart + match[0].length;
|
|
198
|
+
}
|
|
199
|
+
return bufPos + (offsetInExpanded - expandedPos);
|
|
200
|
+
}
|
|
@@ -45,6 +45,8 @@ interface VisualRowInfo {
|
|
|
45
45
|
* Break a line into visual rows using word-aware wrapping.
|
|
46
46
|
* Words are kept intact when possible, breaking at spaces.
|
|
47
47
|
* Long words that exceed width are hard-wrapped.
|
|
48
|
+
* Sentinel blocks are atomic: never split, and occupy visual width
|
|
49
|
+
* equal to their placeholder text length.
|
|
48
50
|
*/
|
|
49
51
|
export declare function getVisualRows(line: string, width: number): VisualRowInfo[];
|
|
50
52
|
/**
|