📝 feat: Improved Textarea Functionality (#1942)

* feat: paste plain text from apps with rich paste data, improved edit message textarea, improved height resizing for long text

* feat(EditMessage): autofocus

* chore: retain user text color when entering edit mode
This commit is contained in:
Danny Avila 2024-03-01 12:46:15 -05:00 committed by GitHub
parent de0cee3f56
commit c52ea9490b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 91 additions and 36 deletions

View file

@ -21,7 +21,7 @@ export default function Textarea({
select: (data) => mergeFileConfig(data), select: (data) => mergeFileConfig(data),
}); });
const { const {
inputRef, textAreaRef,
handlePaste, handlePaste,
handleKeyUp, handleKeyUp,
handleKeyDown, handleKeyDown,
@ -31,7 +31,7 @@ export default function Textarea({
const endpointFileConfig = fileConfig.endpoints[endpoint ?? '']; const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
return ( return (
<TextareaAutosize <TextareaAutosize
ref={inputRef} ref={textAreaRef}
autoFocus autoFocus
value={value} value={value}
disabled={!!disabled} disabled={!!disabled}
@ -52,7 +52,7 @@ export default function Textarea({
: 'pl-3 md:pl-4', : 'pl-3 md:pl-4',
'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 md:pr-12 ', 'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 placeholder-black/50 focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder-white/50 md:py-3.5 md:pr-12 ',
removeFocusOutlines, removeFocusOutlines,
'max-h-52', 'max-h-[65vh] md:max-h-[85vh]',
)} )}
/> />
); );

View file

@ -1,9 +1,11 @@
import { useRef } from 'react'; import { useState, useRef, useEffect } from 'react';
import TextareaAutosize from 'react-textarea-autosize';
import { EModelEndpoint } from 'librechat-data-provider'; import { EModelEndpoint } from 'librechat-data-provider';
import { useUpdateMessageMutation } from 'librechat-data-provider/react-query'; import { useUpdateMessageMutation } from 'librechat-data-provider/react-query';
import Container from '~/components/Messages/Content/Container';
import { useChatContext } from '~/Providers';
import type { TEditProps } from '~/common'; import type { TEditProps } from '~/common';
import Container from '~/components/Messages/Content/Container';
import { cn, removeFocusOutlines } from '~/utils';
import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
const EditMessage = ({ const EditMessage = ({
@ -17,18 +19,28 @@ const EditMessage = ({
}: TEditProps) => { }: TEditProps) => {
const { getMessages, setMessages, conversation } = useChatContext(); const { getMessages, setMessages, conversation } = useChatContext();
const textEditor = useRef<HTMLDivElement | null>(null); const [editedText, setEditedText] = useState<string>(text ?? '');
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const { conversationId, parentMessageId, messageId } = message; const { conversationId, parentMessageId, messageId } = message;
const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null }; const { endpoint: _endpoint, endpointType } = conversation ?? { endpoint: null };
const endpoint = endpointType ?? _endpoint; const endpoint = endpointType ?? _endpoint;
const updateMessageMutation = useUpdateMessageMutation(conversationId ?? ''); const updateMessageMutation = useUpdateMessageMutation(conversationId ?? '');
const localize = useLocalize(); const localize = useLocalize();
useEffect(() => {
const textArea = textAreaRef.current;
if (textArea) {
const length = textArea.value.length;
textArea.focus();
textArea.setSelectionRange(length, length);
}
}, []);
const resubmitMessage = () => { const resubmitMessage = () => {
const text = textEditor?.current?.innerText ?? '';
if (message.isCreatedByUser) { if (message.isCreatedByUser) {
ask({ ask({
text, text: editedText,
parentMessageId, parentMessageId,
conversationId, conversationId,
}); });
@ -44,7 +56,7 @@ const EditMessage = ({
ask( ask(
{ ...parentMessage }, { ...parentMessage },
{ {
editedText: text, editedText,
editedMessageId: messageId, editedMessageId: messageId,
isRegenerate: true, isRegenerate: true,
isEdited: true, isEdited: true,
@ -62,19 +74,18 @@ const EditMessage = ({
if (!messages) { if (!messages) {
return; return;
} }
const text = textEditor?.current?.innerText ?? '';
updateMessageMutation.mutate({ updateMessageMutation.mutate({
conversationId: conversationId ?? '', conversationId: conversationId ?? '',
model: conversation?.model ?? 'gpt-3.5-turbo', model: conversation?.model ?? 'gpt-3.5-turbo',
text: editedText,
messageId, messageId,
text,
}); });
setMessages( setMessages(
messages.map((msg) => messages.map((msg) =>
msg.messageId === messageId msg.messageId === messageId
? { ? {
...msg, ...msg,
text, text: editedText,
isEdited: true, isEdited: true,
} }
: msg, : msg,
@ -85,15 +96,33 @@ const EditMessage = ({
return ( return (
<Container> <Container>
<div <TextareaAutosize
ref={textAreaRef}
onChange={(e) => {
setEditedText(e.target.value);
}}
data-testid="message-text-editor" data-testid="message-text-editor"
className="markdown prose dark:prose-invert light w-full whitespace-pre-wrap break-words border-none focus:outline-none" className={cn(
'markdown prose dark:prose-invert light whitespace-pre-wrap break-words dark:text-gray-20',
'm-0 w-full resize-none border-0 bg-transparent p-0',
removeFocusOutlines,
)}
onPaste={(e) => {
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);
setEditedText(newValue);
}}
contentEditable={true} contentEditable={true}
ref={textEditor} value={editedText}
suppressContentEditableWarning={true} suppressContentEditableWarning={true}
> />
{text}
</div>
<div className="mt-2 flex w-full justify-center text-center"> <div className="mt-2 flex w-full justify-center text-center">
<button <button
className="btn btn-primary relative mr-2" className="btn btn-primary relative mr-2"

View file

@ -1,7 +1,8 @@
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useEffect, useRef } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { EModelEndpoint } from 'librechat-data-provider'; import { EModelEndpoint } from 'librechat-data-provider';
import type { TEndpointOption } from 'librechat-data-provider'; import type { TEndpointOption } from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { KeyboardEvent } from 'react'; import type { KeyboardEvent } from 'react';
import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext'; import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext';
import useGetSender from '~/hooks/Conversations/useGetSender'; import useGetSender from '~/hooks/Conversations/useGetSender';
@ -25,12 +26,20 @@ const getAssistantName = ({
} }
}; };
export default function useTextarea({ setText, submitMessage, disabled = false }) { export default function useTextarea({
setText,
submitMessage,
disabled = false,
}: {
setText: SetterOrUpdater<string>;
submitMessage: () => void;
disabled?: boolean;
}) {
const assistantMap = useAssistantsMapContext(); const assistantMap = useAssistantsMapContext();
const { conversation, isSubmitting, latestMessage, setShowBingToneSetting, setFilesLoading } = const { conversation, isSubmitting, latestMessage, setShowBingToneSetting, setFilesLoading } =
useChatContext(); useChatContext();
const isComposing = useRef(false); const isComposing = useRef(false);
const inputRef = useRef<HTMLTextAreaElement | null>(null); const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const { handleFiles } = useFileHandling(); const { handleFiles } = useFileHandling();
const getSender = useGetSender(); const getSender = useGetSender();
const localize = useLocalize(); const localize = useLocalize();
@ -54,7 +63,7 @@ export default function useTextarea({ setText, submitMessage, disabled = false }
} }
if (conversationId !== 'search') { if (conversationId !== 'search') {
inputRef.current?.focus(); textAreaRef.current?.focus();
} }
// setShowBingToneSetting is a recoil setter, so it doesn't need to be in the dependency array // setShowBingToneSetting is a recoil setter, so it doesn't need to be in the dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -62,14 +71,14 @@ export default function useTextarea({ setText, submitMessage, disabled = false }
useEffect(() => { useEffect(() => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
inputRef.current?.focus(); textAreaRef.current?.focus();
}, 100); }, 100);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [isSubmitting]); }, [isSubmitting]);
useEffect(() => { useEffect(() => {
if (inputRef.current?.value) { if (textAreaRef.current?.value) {
return; return;
} }
@ -91,15 +100,15 @@ export default function useTextarea({ setText, submitMessage, disabled = false }
const placeholder = getPlaceholderText(); const placeholder = getPlaceholderText();
if (inputRef.current?.getAttribute('placeholder') === placeholder) { if (textAreaRef.current?.getAttribute('placeholder') === placeholder) {
return; return;
} }
const setPlaceholder = () => { const setPlaceholder = () => {
const placeholder = getPlaceholderText(); const placeholder = getPlaceholderText();
if (inputRef.current?.getAttribute('placeholder') !== placeholder) { if (textAreaRef.current?.getAttribute('placeholder') !== placeholder) {
inputRef.current?.setAttribute('placeholder', placeholder); textAreaRef.current?.setAttribute('placeholder', placeholder);
} }
}; };
@ -147,16 +156,33 @@ export default function useTextarea({ setText, submitMessage, disabled = false }
isComposing.current = false; isComposing.current = false;
}; };
const handlePaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => { const handlePaste = useCallback(
if (e.clipboardData && e.clipboardData.files.length > 0) { (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
e.preventDefault(); const pastedData = e.clipboardData.getData('text/plain');
setFilesLoading(true); const textArea = textAreaRef.current;
handleFiles(e.clipboardData.files);
} 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);
if (e.clipboardData && e.clipboardData.files.length > 0) {
e.preventDefault();
setFilesLoading(true);
handleFiles(e.clipboardData.files);
}
},
[handleFiles, setFilesLoading, setText],
);
return { return {
inputRef, textAreaRef,
handleKeyDown, handleKeyDown,
handleKeyUp, handleKeyUp,
handlePaste, handlePaste,