✍️ 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:
Danny Avila 2024-03-11 09:18:10 -04:00 committed by GitHub
parent f489aee518
commit f307488dd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 244 additions and 225 deletions

View file

@ -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];
}

View file

@ -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 {