Skip to content

Commit f735a6b

Browse files
moeyue23fu050409
andauthored
feat(sender): mention trigger (#62)
Co-authored-by: 苏向夜 <[email protected]>
1 parent abdeb88 commit f735a6b

File tree

3 files changed

+125
-1
lines changed

3 files changed

+125
-1
lines changed
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@matechat/react": patch:feat
3+
---
4+
5+
Add `Suggestion` component to provide trigger-based autocomplete in textareas.
6+
This component supports multiple trigger characters and grouped options.

src/sender.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
55
import { twMerge } from "tailwind-merge";
66
import PublishNew from "./icons/publish-new.svg";
77
import QuickStop from "./icons/quick-stop.svg";
8+
import type { OnTextInject, TriggerConfig } from "./suggestion";
9+
import Suggestion from "./suggestion";
810
import type { Backend } from "./utils";
911

1012
export interface InputCountProps extends React.ComponentProps<"span"> {
@@ -105,6 +107,10 @@ export interface SenderProps extends React.ComponentProps<"div"> {
105107
*/
106108
onSend?: (controller: AbortController) => void;
107109
toolbar?: React.ReactNode;
110+
/**
111+
* Trigger configurations for the suggestion list
112+
*/
113+
triggerConfigs?: TriggerConfig[];
108114
}
109115

110116
export function Sender({
@@ -115,11 +121,13 @@ export function Sender({
115121
input,
116122
onSend,
117123
toolbar,
124+
triggerConfigs,
118125
...props
119126
}: SenderProps) {
120127
const textareaRef = useRef<HTMLTextAreaElement>(null);
121128
const [message, setMessage] = useState(initialMessage);
122129
const [isSending, setIsSending] = useState(false);
130+
const [caretPosition, setCaretPosition] = useState<number | null>(null);
123131

124132
useEffect(() => {
125133
if (textareaRef.current) {
@@ -129,6 +137,15 @@ export function Sender({
129137
onMessageChange?.(message);
130138
}, [message, onMessageChange]);
131139

140+
useEffect(() => {
141+
if (textareaRef.current && caretPosition !== null) {
142+
textareaRef.current.focus();
143+
textareaRef.current.selectionStart = caretPosition;
144+
textareaRef.current.selectionEnd = caretPosition;
145+
setCaretPosition(null);
146+
}
147+
}, [caretPosition]);
148+
132149
const [controller, setController] = useState<AbortController | null>(null);
133150
const handleSend = useCallback(() => {
134151
if (isSending) {
@@ -175,19 +192,41 @@ export function Sender({
175192
[],
176193
);
177194

195+
const handleTextInject: OnTextInject = useCallback(
196+
(newText, suggestionStartPosition) => {
197+
setMessage((prevMessage) => {
198+
const currentCaretPosition =
199+
textareaRef.current?.selectionStart ?? prevMessage.length;
200+
const textBefore = prevMessage.slice(0, suggestionStartPosition);
201+
const textAfter = prevMessage.slice(currentCaretPosition);
202+
const newMessage = textBefore + newText + textAfter;
203+
return newMessage;
204+
});
205+
setCaretPosition(suggestionStartPosition + newText.length);
206+
},
207+
[],
208+
);
178209
return (
179210
<div
180211
data-slot="sender"
181212
className={twMerge(
182213
clsx(
183-
"px-1 flex flex-col items-center border rounded-2xl",
214+
"relative px-1 flex flex-col items-center border rounded-2xl",
184215
"border-gray-200 dark:border-gray-700 shadow-sm transition-all duration-300 hover:shadow-md",
185216
"focus-within:ring-2 focus-within:ring-blue-500 focus-within:border-blue-500",
186217
className,
187218
),
188219
)}
189220
{...props}
190221
>
222+
<div className="absolute bottom-full left-0 w-full bg-white dark:bg-gray-50 rounded-lg shadow-amber-50 max-h-64 overflow-y-auto">
223+
<Suggestion
224+
message={message}
225+
textareaRef={textareaRef}
226+
triggerConfigs={triggerConfigs ?? []}
227+
onInject={handleTextInject}
228+
/>
229+
</div>
191230
<textarea
192231
ref={textareaRef}
193232
value={message}

src/suggestion.tsx

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import "./tailwind.css";
2+
import React from "react";
3+
import type { SelectItemOptionsType } from "./list";
4+
import { List } from "./list";
5+
6+
export interface TriggerConfig {
7+
char: string;
8+
data: SelectItemOptionsType;
9+
optionLabel: string;
10+
optionGroupLabel?: string;
11+
optionGroupChildren?: string;
12+
}
13+
14+
export type OnTextInject = (newText: string, newCursorPosition: number) => void;
15+
16+
interface SuggestionProps {
17+
message: string;
18+
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
19+
triggerConfigs: TriggerConfig[];
20+
onInject: OnTextInject;
21+
}
22+
23+
const useSuggestionContext = (
24+
message: string,
25+
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
26+
triggerConfigs: TriggerConfig[],
27+
) => {
28+
const context = React.useMemo(() => {
29+
const textarea = textareaRef?.current;
30+
if (!textarea) return null;
31+
32+
const caretPostionIndex = textarea.selectionStart ?? message.length;
33+
let currentConfig: TriggerConfig | undefined;
34+
let triggerIndex = -1;
35+
let queryText = "";
36+
37+
for (let i = caretPostionIndex - 1; i >= 0; i--) {
38+
const char = message[i];
39+
const matchedConfig = triggerConfigs.find(
40+
(config) => config.char === char,
41+
);
42+
if (matchedConfig) {
43+
currentConfig = matchedConfig;
44+
triggerIndex = i;
45+
queryText = message.slice(i + 1, caretPostionIndex);
46+
break;
47+
}
48+
}
49+
if (!currentConfig) return null;
50+
return {
51+
currentConfig,
52+
triggerIndex,
53+
queryText,
54+
};
55+
}, [message, textareaRef, triggerConfigs]);
56+
return context;
57+
};
58+
export default function Suggestion({
59+
message,
60+
textareaRef,
61+
triggerConfigs,
62+
onInject,
63+
}: SuggestionProps) {
64+
const context = useSuggestionContext(message, textareaRef, triggerConfigs);
65+
return (
66+
<div>
67+
<List
68+
options={context?.currentConfig.data}
69+
optionLabel={context?.currentConfig.optionLabel ?? "default value"}
70+
optionGroupLabel={context?.currentConfig.optionGroupLabel}
71+
optionGroupChildren={context?.currentConfig.optionGroupChildren}
72+
onChange={(e) => {
73+
const newText: string = (e.value ?? e.target.value) as string;
74+
onInject(newText, context?.triggerIndex ?? -1);
75+
}}
76+
/>
77+
</div>
78+
);
79+
}

0 commit comments

Comments
 (0)