@assistant-ui/react-ink 0.0.11 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/context/providers/RuntimeAdapterProvider.d.ts +1 -1
- package/dist/context/providers/RuntimeAdapterProvider.d.ts.map +1 -1
- package/dist/context/providers/RuntimeAdapterProvider.js.map +1 -1
- package/dist/primitives/composer/ComposerInput.d.ts +3 -4
- package/dist/primitives/composer/ComposerInput.d.ts.map +1 -1
- package/dist/primitives/composer/ComposerInput.js +151 -7
- package/dist/primitives/composer/ComposerInput.js.map +1 -1
- package/dist/primitives/composer/useTextBuffer.d.ts +58 -0
- package/dist/primitives/composer/useTextBuffer.d.ts.map +1 -0
- package/dist/primitives/composer/useTextBuffer.js +211 -0
- package/dist/primitives/composer/useTextBuffer.js.map +1 -0
- package/dist/primitives/threadList/ThreadListItems.d.ts +1 -1
- package/dist/primitives/threadList/ThreadListItems.d.ts.map +1 -1
- package/package.json +9 -9
- package/src/context/providers/RuntimeAdapterProvider.tsx +1 -1
- package/src/primitives/composer/ComposerInput.tsx +194 -10
- package/src/primitives/composer/useTextBuffer.ts +332 -0
- package/src/primitives/threadList/ThreadListItems.tsx +1 -1
- package/src/tests/ComposerInput.test.tsx +527 -0
- package/src/tests/useTextBuffer.test.ts +246 -0
|
@@ -1,49 +1,233 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
1
2
|
import type { ComponentProps } from "react";
|
|
2
3
|
import { Box, Text, useFocus, useInput } from "ink";
|
|
3
4
|
import { useAui, useAuiState } from "@assistant-ui/store";
|
|
5
|
+
import {
|
|
6
|
+
getGraphemeAt,
|
|
7
|
+
textBufferReducer,
|
|
8
|
+
useTextBuffer,
|
|
9
|
+
} from "./useTextBuffer";
|
|
10
|
+
|
|
11
|
+
// cap dedup map so a store that drops echoes can't grow the counter without bound
|
|
12
|
+
const PENDING_SYNC_CAP = 64;
|
|
4
13
|
|
|
5
14
|
export type ComposerInputProps = ComponentProps<typeof Box> & {
|
|
6
|
-
/** Submit the message when Enter is pressed. @default false */
|
|
7
15
|
submitOnEnter?: boolean | undefined;
|
|
8
|
-
/** Placeholder text shown when the input is empty. */
|
|
9
16
|
placeholder?: string | undefined;
|
|
10
|
-
/** Whether this input should receive focus automatically. @default true */
|
|
11
17
|
autoFocus?: boolean | undefined;
|
|
18
|
+
multiLine?: boolean | undefined;
|
|
19
|
+
onSubmit?: ((text: string) => void) | undefined;
|
|
12
20
|
};
|
|
13
21
|
|
|
14
22
|
export const ComposerInput = ({
|
|
15
23
|
submitOnEnter = false,
|
|
16
24
|
placeholder = "",
|
|
17
25
|
autoFocus = true,
|
|
26
|
+
multiLine = false,
|
|
27
|
+
onSubmit,
|
|
18
28
|
...boxProps
|
|
19
29
|
}: ComposerInputProps) => {
|
|
20
30
|
const aui = useAui();
|
|
21
|
-
const
|
|
31
|
+
const storeText = useAuiState((s) => s.composer.text);
|
|
22
32
|
const { isFocused } = useFocus({ autoFocus });
|
|
33
|
+
const { text, cursorOffset, preferredColumn, dispatchAction, setText } =
|
|
34
|
+
useTextBuffer(storeText);
|
|
35
|
+
const bufferStateRef = useRef({ text, cursorOffset, preferredColumn });
|
|
36
|
+
const pendingLocalSyncTextsRef = useRef(new Map<string, number>());
|
|
37
|
+
bufferStateRef.current = { text, cursorOffset, preferredColumn };
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const counter = pendingLocalSyncTextsRef.current;
|
|
41
|
+
const pending = counter.get(storeText) ?? 0;
|
|
42
|
+
if (pending > 0) {
|
|
43
|
+
if (pending === 1) counter.delete(storeText);
|
|
44
|
+
else counter.set(storeText, pending - 1);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (storeText === text) return;
|
|
48
|
+
|
|
49
|
+
counter.clear();
|
|
50
|
+
setText(storeText);
|
|
51
|
+
bufferStateRef.current = {
|
|
52
|
+
text: storeText,
|
|
53
|
+
cursorOffset: storeText.length,
|
|
54
|
+
preferredColumn: undefined,
|
|
55
|
+
};
|
|
56
|
+
}, [setText, storeText, text]);
|
|
57
|
+
|
|
58
|
+
const commitAction = (
|
|
59
|
+
action: Parameters<typeof textBufferReducer>[1],
|
|
60
|
+
options?: { syncText?: boolean },
|
|
61
|
+
) => {
|
|
62
|
+
const currentState = bufferStateRef.current;
|
|
63
|
+
// run the reducer eagerly so submit-after-edit sees post-action state before react commits
|
|
64
|
+
const nextState = textBufferReducer(currentState, action);
|
|
65
|
+
dispatchAction(action);
|
|
66
|
+
bufferStateRef.current = nextState;
|
|
67
|
+
|
|
68
|
+
if (options?.syncText !== false && nextState.text !== currentState.text) {
|
|
69
|
+
const counter = pendingLocalSyncTextsRef.current;
|
|
70
|
+
if (counter.size >= PENDING_SYNC_CAP) counter.clear();
|
|
71
|
+
counter.set(nextState.text, (counter.get(nextState.text) ?? 0) + 1);
|
|
72
|
+
aui.composer().setText(nextState.text);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const submit = () => {
|
|
77
|
+
const submittedText = bufferStateRef.current.text;
|
|
78
|
+
if (onSubmit) {
|
|
79
|
+
onSubmit(submittedText);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
aui.composer().send();
|
|
84
|
+
};
|
|
23
85
|
|
|
24
86
|
useInput(
|
|
25
87
|
(input, key) => {
|
|
88
|
+
const extendedKey = key as typeof key & {
|
|
89
|
+
home?: boolean;
|
|
90
|
+
end?: boolean;
|
|
91
|
+
shift?: boolean;
|
|
92
|
+
};
|
|
93
|
+
const lowerInput = input.toLowerCase();
|
|
94
|
+
|
|
95
|
+
if (key.ctrl) {
|
|
96
|
+
// ctrl+j may also report key.return; swallow so single-line never submits
|
|
97
|
+
if (lowerInput === "j") {
|
|
98
|
+
if (multiLine) {
|
|
99
|
+
commitAction({ type: "insert", text: "\n" });
|
|
100
|
+
}
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (lowerInput === "a") {
|
|
104
|
+
commitAction({ type: "move-home", multiLine }, { syncText: false });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (lowerInput === "e") {
|
|
108
|
+
commitAction({ type: "move-end", multiLine }, { syncText: false });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (lowerInput === "w") {
|
|
112
|
+
commitAction({ type: "kill-word-backward" });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (lowerInput === "u") {
|
|
116
|
+
commitAction({ type: "kill-start", multiLine });
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (lowerInput === "k") {
|
|
120
|
+
commitAction({ type: "kill-end", multiLine });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (lowerInput === "d") {
|
|
124
|
+
commitAction({ type: "delete-forward" });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (key.meta) {
|
|
130
|
+
if (lowerInput === "b") {
|
|
131
|
+
commitAction({ type: "move-word-left" }, { syncText: false });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (lowerInput === "f") {
|
|
135
|
+
commitAction({ type: "move-word-right" }, { syncText: false });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (lowerInput === "d") {
|
|
139
|
+
commitAction({ type: "kill-word-forward" });
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
26
144
|
if (key.return) {
|
|
145
|
+
const shouldInsertNewline =
|
|
146
|
+
multiLine && (!submitOnEnter || extendedKey.shift);
|
|
147
|
+
if (shouldInsertNewline) {
|
|
148
|
+
commitAction({ type: "insert", text: "\n" });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
27
152
|
if (submitOnEnter) {
|
|
28
|
-
|
|
153
|
+
submit();
|
|
29
154
|
}
|
|
30
155
|
return;
|
|
31
156
|
}
|
|
32
|
-
|
|
33
|
-
|
|
157
|
+
|
|
158
|
+
if (key.leftArrow) {
|
|
159
|
+
commitAction({ type: "move-left" }, { syncText: false });
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (key.rightArrow) {
|
|
164
|
+
commitAction({ type: "move-right" }, { syncText: false });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (multiLine && key.upArrow) {
|
|
169
|
+
commitAction({ type: "move-up" }, { syncText: false });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (multiLine && key.downArrow) {
|
|
174
|
+
commitAction({ type: "move-down" }, { syncText: false });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (extendedKey.home) {
|
|
179
|
+
commitAction({ type: "move-home", multiLine }, { syncText: false });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (extendedKey.end) {
|
|
184
|
+
commitAction({ type: "move-end", multiLine }, { syncText: false });
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (key.backspace) {
|
|
189
|
+
commitAction({ type: "delete-backward" });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (key.delete) {
|
|
194
|
+
commitAction({ type: "delete-forward" });
|
|
34
195
|
return;
|
|
35
196
|
}
|
|
197
|
+
|
|
36
198
|
if (input && !key.ctrl && !key.meta) {
|
|
37
|
-
|
|
199
|
+
commitAction({ type: "insert", text: input });
|
|
38
200
|
}
|
|
39
201
|
},
|
|
40
202
|
{ isActive: isFocused },
|
|
41
203
|
);
|
|
42
204
|
|
|
205
|
+
const hasText = text.length > 0;
|
|
206
|
+
const isShowingPlaceholder = !hasText && placeholder.length > 0;
|
|
207
|
+
const before = hasText ? text.slice(0, cursorOffset) : "";
|
|
208
|
+
const charAtCursor = hasText ? getGraphemeAt(text, cursorOffset) : "";
|
|
209
|
+
const isOnNewline = charAtCursor === "\n";
|
|
210
|
+
// render a space when on a newline so the inverse cursor cell stays visible
|
|
211
|
+
const atCursor = charAtCursor === "" || isOnNewline ? " " : charAtCursor;
|
|
212
|
+
const after = hasText
|
|
213
|
+
? isOnNewline
|
|
214
|
+
? text.slice(cursorOffset)
|
|
215
|
+
: text.slice(cursorOffset + charAtCursor.length)
|
|
216
|
+
: placeholder;
|
|
217
|
+
|
|
43
218
|
return (
|
|
44
219
|
<Box {...boxProps}>
|
|
45
|
-
|
|
46
|
-
|
|
220
|
+
{!isFocused ? (
|
|
221
|
+
<Text dimColor={isShowingPlaceholder}>
|
|
222
|
+
{hasText ? text : placeholder}
|
|
223
|
+
</Text>
|
|
224
|
+
) : (
|
|
225
|
+
<Text dimColor={isShowingPlaceholder}>
|
|
226
|
+
{before}
|
|
227
|
+
<Text inverse>{atCursor}</Text>
|
|
228
|
+
{after}
|
|
229
|
+
</Text>
|
|
230
|
+
)}
|
|
47
231
|
</Box>
|
|
48
232
|
);
|
|
49
233
|
};
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { useCallback, useReducer } from "react";
|
|
2
|
+
|
|
3
|
+
export type TextBufferState = {
|
|
4
|
+
text: string;
|
|
5
|
+
cursorOffset: number;
|
|
6
|
+
preferredColumn: number | undefined;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type TextBufferAction =
|
|
10
|
+
| { type: "insert"; text: string }
|
|
11
|
+
| { type: "delete-backward" }
|
|
12
|
+
| { type: "delete-forward" }
|
|
13
|
+
| { type: "move-left" }
|
|
14
|
+
| { type: "move-right" }
|
|
15
|
+
| { type: "move-up" }
|
|
16
|
+
| { type: "move-down" }
|
|
17
|
+
| { type: "move-home"; multiLine: boolean }
|
|
18
|
+
| { type: "move-end"; multiLine: boolean }
|
|
19
|
+
| { type: "move-word-left" }
|
|
20
|
+
| { type: "move-word-right" }
|
|
21
|
+
| { type: "kill-word-backward" }
|
|
22
|
+
| { type: "kill-word-forward" }
|
|
23
|
+
| { type: "kill-start"; multiLine: boolean }
|
|
24
|
+
| { type: "kill-end"; multiLine: boolean }
|
|
25
|
+
| { type: "set-text"; text: string }
|
|
26
|
+
| { type: "set-cursor"; cursorOffset: number };
|
|
27
|
+
|
|
28
|
+
const clamp = (value: number, min: number, max: number) =>
|
|
29
|
+
Math.min(Math.max(value, min), max);
|
|
30
|
+
|
|
31
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, {
|
|
32
|
+
granularity: "grapheme",
|
|
33
|
+
});
|
|
34
|
+
const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
35
|
+
|
|
36
|
+
const stepGraphemeLeft = (text: string, offset: number) => {
|
|
37
|
+
if (offset <= 0) return 0;
|
|
38
|
+
let previous = 0;
|
|
39
|
+
for (const { index } of graphemeSegmenter.segment(text)) {
|
|
40
|
+
if (index >= offset) break;
|
|
41
|
+
previous = index;
|
|
42
|
+
}
|
|
43
|
+
return previous;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const stepGraphemeRight = (text: string, offset: number) => {
|
|
47
|
+
if (offset >= text.length) return text.length;
|
|
48
|
+
for (const { index, segment } of graphemeSegmenter.segment(text)) {
|
|
49
|
+
const end = index + segment.length;
|
|
50
|
+
if (end > offset) return end;
|
|
51
|
+
}
|
|
52
|
+
return text.length;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const getGraphemeAt = (text: string, offset: number) => {
|
|
56
|
+
if (offset >= text.length) return "";
|
|
57
|
+
for (const { index, segment } of graphemeSegmenter.segment(text)) {
|
|
58
|
+
if (index === offset) return segment;
|
|
59
|
+
if (index > offset) return "";
|
|
60
|
+
}
|
|
61
|
+
return "";
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const getLineStart = (text: string, cursorOffset: number) => {
|
|
65
|
+
if (cursorOffset === 0) return 0;
|
|
66
|
+
const lineBreakIndex = text.lastIndexOf("\n", cursorOffset - 1);
|
|
67
|
+
return lineBreakIndex === -1 ? 0 : lineBreakIndex + 1;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const getLineEnd = (text: string, cursorOffset: number) => {
|
|
71
|
+
const lineBreakIndex = text.indexOf("\n", cursorOffset);
|
|
72
|
+
return lineBreakIndex === -1 ? text.length : lineBreakIndex;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const getLineRange = (text: string, cursorOffset: number) => {
|
|
76
|
+
const start = getLineStart(text, cursorOffset);
|
|
77
|
+
const end = getLineEnd(text, cursorOffset);
|
|
78
|
+
return { start, end };
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const getPreviousWordOffset = (text: string, cursorOffset: number) => {
|
|
82
|
+
let result = 0;
|
|
83
|
+
for (const segment of wordSegmenter.segment(text)) {
|
|
84
|
+
if (segment.index >= cursorOffset) break;
|
|
85
|
+
if (segment.isWordLike) result = segment.index;
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const getNextWordOffset = (text: string, cursorOffset: number) => {
|
|
91
|
+
for (const segment of wordSegmenter.segment(text)) {
|
|
92
|
+
const end = segment.index + segment.segment.length;
|
|
93
|
+
if (end <= cursorOffset) continue;
|
|
94
|
+
if (segment.isWordLike) return end;
|
|
95
|
+
}
|
|
96
|
+
return text.length;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const moveVertical = (
|
|
100
|
+
text: string,
|
|
101
|
+
cursorOffset: number,
|
|
102
|
+
preferredColumn: number | undefined,
|
|
103
|
+
direction: -1 | 1,
|
|
104
|
+
) => {
|
|
105
|
+
const { start, end } = getLineRange(text, cursorOffset);
|
|
106
|
+
const currentColumn = preferredColumn ?? cursorOffset - start;
|
|
107
|
+
const adjacentBreakIndex = direction === -1 ? start - 1 : end;
|
|
108
|
+
|
|
109
|
+
if (adjacentBreakIndex < 0 || adjacentBreakIndex >= text.length) {
|
|
110
|
+
return { cursorOffset, preferredColumn: currentColumn };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const adjacentCursorBase =
|
|
114
|
+
direction === -1 ? adjacentBreakIndex : adjacentBreakIndex + 1;
|
|
115
|
+
const adjacentRange = getLineRange(text, adjacentCursorBase);
|
|
116
|
+
const nextCursorOffset = clamp(
|
|
117
|
+
adjacentRange.start + currentColumn,
|
|
118
|
+
adjacentRange.start,
|
|
119
|
+
adjacentRange.end,
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
cursorOffset: nextCursorOffset,
|
|
124
|
+
preferredColumn: currentColumn,
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const clearPreferredColumn = (
|
|
129
|
+
state: TextBufferState,
|
|
130
|
+
cursorOffset: number,
|
|
131
|
+
) => ({
|
|
132
|
+
...state,
|
|
133
|
+
cursorOffset,
|
|
134
|
+
preferredColumn: undefined,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export const textBufferReducer = (
|
|
138
|
+
state: TextBufferState,
|
|
139
|
+
action: TextBufferAction,
|
|
140
|
+
): TextBufferState => {
|
|
141
|
+
switch (action.type) {
|
|
142
|
+
case "insert": {
|
|
143
|
+
if (!action.text) return state;
|
|
144
|
+
|
|
145
|
+
const nextText =
|
|
146
|
+
state.text.slice(0, state.cursorOffset) +
|
|
147
|
+
action.text +
|
|
148
|
+
state.text.slice(state.cursorOffset);
|
|
149
|
+
const nextCursorOffset = state.cursorOffset + action.text.length;
|
|
150
|
+
return clearPreferredColumn(
|
|
151
|
+
{ ...state, text: nextText },
|
|
152
|
+
nextCursorOffset,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "delete-backward": {
|
|
157
|
+
if (state.cursorOffset === 0) return state;
|
|
158
|
+
|
|
159
|
+
const previousOffset = stepGraphemeLeft(state.text, state.cursorOffset);
|
|
160
|
+
const nextText =
|
|
161
|
+
state.text.slice(0, previousOffset) +
|
|
162
|
+
state.text.slice(state.cursorOffset);
|
|
163
|
+
return clearPreferredColumn({ ...state, text: nextText }, previousOffset);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
case "delete-forward": {
|
|
167
|
+
if (state.cursorOffset >= state.text.length) return state;
|
|
168
|
+
|
|
169
|
+
const nextOffset = stepGraphemeRight(state.text, state.cursorOffset);
|
|
170
|
+
const nextText =
|
|
171
|
+
state.text.slice(0, state.cursorOffset) + state.text.slice(nextOffset);
|
|
172
|
+
return clearPreferredColumn(
|
|
173
|
+
{ ...state, text: nextText },
|
|
174
|
+
state.cursorOffset,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
case "move-left":
|
|
179
|
+
return clearPreferredColumn(
|
|
180
|
+
state,
|
|
181
|
+
stepGraphemeLeft(state.text, state.cursorOffset),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
case "move-right":
|
|
185
|
+
return clearPreferredColumn(
|
|
186
|
+
state,
|
|
187
|
+
stepGraphemeRight(state.text, state.cursorOffset),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
case "move-up": {
|
|
191
|
+
const next = moveVertical(
|
|
192
|
+
state.text,
|
|
193
|
+
state.cursorOffset,
|
|
194
|
+
state.preferredColumn,
|
|
195
|
+
-1,
|
|
196
|
+
);
|
|
197
|
+
return { ...state, ...next };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
case "move-down": {
|
|
201
|
+
const next = moveVertical(
|
|
202
|
+
state.text,
|
|
203
|
+
state.cursorOffset,
|
|
204
|
+
state.preferredColumn,
|
|
205
|
+
1,
|
|
206
|
+
);
|
|
207
|
+
return { ...state, ...next };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
case "move-home": {
|
|
211
|
+
const nextCursorOffset = action.multiLine
|
|
212
|
+
? getLineStart(state.text, state.cursorOffset)
|
|
213
|
+
: 0;
|
|
214
|
+
return clearPreferredColumn(state, nextCursorOffset);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
case "move-end": {
|
|
218
|
+
const nextCursorOffset = action.multiLine
|
|
219
|
+
? getLineEnd(state.text, state.cursorOffset)
|
|
220
|
+
: state.text.length;
|
|
221
|
+
return clearPreferredColumn(state, nextCursorOffset);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
case "move-word-left":
|
|
225
|
+
return clearPreferredColumn(
|
|
226
|
+
state,
|
|
227
|
+
getPreviousWordOffset(state.text, state.cursorOffset),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
case "move-word-right":
|
|
231
|
+
return clearPreferredColumn(
|
|
232
|
+
state,
|
|
233
|
+
getNextWordOffset(state.text, state.cursorOffset),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
case "kill-word-backward": {
|
|
237
|
+
const nextCursorOffset = getPreviousWordOffset(
|
|
238
|
+
state.text,
|
|
239
|
+
state.cursorOffset,
|
|
240
|
+
);
|
|
241
|
+
if (nextCursorOffset === state.cursorOffset) return state;
|
|
242
|
+
|
|
243
|
+
const nextText =
|
|
244
|
+
state.text.slice(0, nextCursorOffset) +
|
|
245
|
+
state.text.slice(state.cursorOffset);
|
|
246
|
+
return clearPreferredColumn(
|
|
247
|
+
{ ...state, text: nextText },
|
|
248
|
+
nextCursorOffset,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case "kill-word-forward": {
|
|
253
|
+
const nextOffset = getNextWordOffset(state.text, state.cursorOffset);
|
|
254
|
+
if (nextOffset === state.cursorOffset) return state;
|
|
255
|
+
|
|
256
|
+
const nextText =
|
|
257
|
+
state.text.slice(0, state.cursorOffset) + state.text.slice(nextOffset);
|
|
258
|
+
return clearPreferredColumn(
|
|
259
|
+
{ ...state, text: nextText },
|
|
260
|
+
state.cursorOffset,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
case "kill-start": {
|
|
265
|
+
const rangeStart = action.multiLine
|
|
266
|
+
? getLineStart(state.text, state.cursorOffset)
|
|
267
|
+
: 0;
|
|
268
|
+
if (rangeStart === state.cursorOffset) return state;
|
|
269
|
+
|
|
270
|
+
const nextText =
|
|
271
|
+
state.text.slice(0, rangeStart) + state.text.slice(state.cursorOffset);
|
|
272
|
+
return clearPreferredColumn({ ...state, text: nextText }, rangeStart);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case "kill-end": {
|
|
276
|
+
const lineEnd = action.multiLine
|
|
277
|
+
? getLineEnd(state.text, state.cursorOffset)
|
|
278
|
+
: state.text.length;
|
|
279
|
+
// emacs convention: ctrl+k at EOL kills the trailing newline so the next line joins
|
|
280
|
+
const rangeEnd =
|
|
281
|
+
action.multiLine &&
|
|
282
|
+
lineEnd === state.cursorOffset &&
|
|
283
|
+
lineEnd < state.text.length
|
|
284
|
+
? lineEnd + 1
|
|
285
|
+
: lineEnd;
|
|
286
|
+
if (rangeEnd === state.cursorOffset) return state;
|
|
287
|
+
|
|
288
|
+
const nextText =
|
|
289
|
+
state.text.slice(0, state.cursorOffset) + state.text.slice(rangeEnd);
|
|
290
|
+
return clearPreferredColumn(
|
|
291
|
+
{ ...state, text: nextText },
|
|
292
|
+
state.cursorOffset,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
case "set-text":
|
|
297
|
+
return {
|
|
298
|
+
text: action.text,
|
|
299
|
+
cursorOffset: action.text.length,
|
|
300
|
+
preferredColumn: undefined,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
case "set-cursor":
|
|
304
|
+
return clearPreferredColumn(
|
|
305
|
+
state,
|
|
306
|
+
clamp(action.cursorOffset, 0, state.text.length),
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
export const createTextBufferState = (text = ""): TextBufferState => ({
|
|
312
|
+
text,
|
|
313
|
+
cursorOffset: text.length,
|
|
314
|
+
preferredColumn: undefined,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
export const useTextBuffer = (text = "") => {
|
|
318
|
+
const [state, dispatch] = useReducer(
|
|
319
|
+
textBufferReducer,
|
|
320
|
+
createTextBufferState(text),
|
|
321
|
+
);
|
|
322
|
+
const setText = useCallback(
|
|
323
|
+
(nextText: string) => dispatch({ type: "set-text", text: nextText }),
|
|
324
|
+
[],
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
...state,
|
|
329
|
+
dispatchAction: dispatch,
|
|
330
|
+
setText,
|
|
331
|
+
};
|
|
332
|
+
};
|