mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
✍️ refactor(Textarea): Optimize Text Input & Enhance UX (#2058)
* refactor(useDebouncedInput): make object as input arg and accept setter
* refactor(ChatForm/Textarea): consolidate textarea/form logic to one component, use react-hook-form, programmatically click send button instead of passing submitMessage, forwardRef and memoize SendButton
* refactor(Textarea): use Controller field value to avoid manual update of ref
* chore: remove forms provider
* chore: memoize AttachFile
* refactor(ChatForm/SendButton): only re-render SendButton when there is text input
* chore: make iconURL bigger
* chore: optimize Root/Nav
* refactor(SendButton): memoize disabled prop based on text
* chore: memoize Nav and ChatForm
* chore: remove textarea ref text on submission
* feat(EditMessage): Make Esc exit the edit mode and dismiss changes when editing a message
* style(MenuItem): Display the ☑️ icon only on the selected model
This commit is contained in:
parent
f489aee518
commit
f307488dd4
16 changed files with 244 additions and 225 deletions
|
|
@ -1,34 +1,48 @@
|
|||
import debounce from 'lodash/debounce';
|
||||
import { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { TSetOption } from '~/common';
|
||||
|
||||
/** A custom hook that accepts a setOption function and an option key (e.g., 'title').
|
||||
It manages a local state for the option value, a debounced setter function for that value,
|
||||
and returns the local state value, its setter, and an onChange handler suitable for inputs. */
|
||||
function useDebouncedInput(
|
||||
setOption: TSetOption,
|
||||
optionKey: string | number,
|
||||
initialValue: unknown,
|
||||
function useDebouncedInput({
|
||||
setOption,
|
||||
setter,
|
||||
optionKey,
|
||||
initialValue,
|
||||
delay = 450,
|
||||
): [
|
||||
}: {
|
||||
setOption?: TSetOption;
|
||||
setter?: SetterOrUpdater<string>;
|
||||
optionKey?: string | number;
|
||||
initialValue: unknown;
|
||||
delay?: number;
|
||||
}): [
|
||||
React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>,
|
||||
unknown,
|
||||
React.Dispatch<React.SetStateAction<unknown>>,
|
||||
SetterOrUpdater<string>,
|
||||
// (newValue: string) => void,
|
||||
] {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
/** A debounced function to call the passed setOption with the optionKey and new value.
|
||||
*
|
||||
Note: We use useCallback to ensure our debounced function is stable across renders. */
|
||||
const setDebouncedOption = useCallback(debounce(setOption(optionKey), delay), []);
|
||||
const setDebouncedOption = useCallback(
|
||||
debounce(setOption && optionKey ? setOption(optionKey) : setter, delay),
|
||||
[],
|
||||
);
|
||||
|
||||
/** An onChange handler that updates the local state and the debounced option */
|
||||
const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
|
||||
const newValue = e.target.value;
|
||||
setValue(newValue);
|
||||
setDebouncedOption(newValue);
|
||||
};
|
||||
|
||||
const onChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement> = useCallback(
|
||||
(e) => {
|
||||
const newValue: unknown = e.target.value;
|
||||
setValue(newValue);
|
||||
setDebouncedOption(newValue);
|
||||
},
|
||||
[setDebouncedOption],
|
||||
);
|
||||
return [onChange, value, setValue];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import debounce from 'lodash/debounce';
|
|||
import React, { useEffect, useRef, useCallback } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TEndpointOption } from 'librechat-data-provider';
|
||||
import type { SetterOrUpdater } from 'recoil';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
|
||||
import useGetSender from '~/hooks/Conversations/useGetSender';
|
||||
|
|
@ -12,6 +11,26 @@ import useLocalize from '~/hooks/useLocalize';
|
|||
|
||||
type KeyEvent = KeyboardEvent<HTMLTextAreaElement>;
|
||||
|
||||
function insertTextAtCursor(element: HTMLTextAreaElement, textToInsert: string) {
|
||||
// Focus the element to ensure the insertion point is updated
|
||||
element.focus();
|
||||
|
||||
// Use the browser's built-in undoable actions if possible
|
||||
if (window.getSelection() && document.queryCommandSupported('insertText')) {
|
||||
document.execCommand('insertText', false, textToInsert);
|
||||
} else {
|
||||
console.warn('insertTextAtCursor: document.execCommand is not supported');
|
||||
const startPos = element.selectionStart;
|
||||
const endPos = element.selectionEnd;
|
||||
const beforeText = element.value.substring(0, startPos);
|
||||
const afterText = element.value.substring(endPos);
|
||||
element.value = beforeText + textToInsert + afterText;
|
||||
element.selectionStart = element.selectionEnd = startPos + textToInsert.length;
|
||||
const event = new Event('input', { bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
const getAssistantName = ({
|
||||
name,
|
||||
localize,
|
||||
|
|
@ -27,19 +46,18 @@ const getAssistantName = ({
|
|||
};
|
||||
|
||||
export default function useTextarea({
|
||||
setText,
|
||||
submitMessage,
|
||||
textAreaRef,
|
||||
submitButtonRef,
|
||||
disabled = false,
|
||||
}: {
|
||||
setText: SetterOrUpdater<string>;
|
||||
submitMessage: () => void;
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const assistantMap = useAssistantsMapContext();
|
||||
const { conversation, isSubmitting, latestMessage, setShowBingToneSetting, setFilesLoading } =
|
||||
useChatContext();
|
||||
const isComposing = useRef(false);
|
||||
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const { handleFiles } = useFileHandling();
|
||||
const getSender = useGetSender();
|
||||
const localize = useLocalize();
|
||||
|
|
@ -77,7 +95,7 @@ export default function useTextarea({
|
|||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isSubmitting]);
|
||||
}, [isSubmitting, textAreaRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (textAreaRef.current?.value) {
|
||||
|
|
@ -118,7 +136,16 @@ export default function useTextarea({
|
|||
debouncedSetPlaceholder();
|
||||
|
||||
return () => debouncedSetPlaceholder.cancel();
|
||||
}, [conversation, disabled, latestMessage, isNotAppendable, localize, getSender, assistantName]);
|
||||
}, [
|
||||
conversation,
|
||||
disabled,
|
||||
latestMessage,
|
||||
isNotAppendable,
|
||||
localize,
|
||||
getSender,
|
||||
assistantName,
|
||||
textAreaRef,
|
||||
]);
|
||||
|
||||
const handleKeyDown = (e: KeyEvent) => {
|
||||
if (e.key === 'Enter' && isSubmitting) {
|
||||
|
|
@ -130,7 +157,7 @@ export default function useTextarea({
|
|||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing?.current) {
|
||||
submitMessage();
|
||||
submitButtonRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -138,7 +165,7 @@ export default function useTextarea({
|
|||
const target = e.target as HTMLTextAreaElement;
|
||||
|
||||
if (e.keyCode === 8 && target.value.trim() === '') {
|
||||
setText(target.value);
|
||||
textAreaRef.current?.setRangeText('', 0, textAreaRef.current?.value?.length, 'end');
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
|
|
@ -161,20 +188,13 @@ export default function useTextarea({
|
|||
const handlePaste = useCallback(
|
||||
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const pastedData = e.clipboardData.getData('text/plain');
|
||||
const textArea = textAreaRef.current;
|
||||
|
||||
if (!textArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
const start = textArea.selectionStart;
|
||||
const end = textArea.selectionEnd;
|
||||
|
||||
const newValue =
|
||||
textArea.value.substring(0, start) + pastedData + textArea.value.substring(end);
|
||||
setText(newValue);
|
||||
const pastedData = e.clipboardData.getData('text/plain');
|
||||
insertTextAtCursor(textArea, pastedData);
|
||||
|
||||
if (e.clipboardData && e.clipboardData.files.length > 0) {
|
||||
e.preventDefault();
|
||||
|
|
@ -189,7 +209,7 @@ export default function useTextarea({
|
|||
handleFiles(timestampedFiles);
|
||||
}
|
||||
},
|
||||
[handleFiles, setFilesLoading, setText],
|
||||
[handleFiles, setFilesLoading, textAreaRef],
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue