mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 09:20:15 +01:00
📝 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:
parent
de0cee3f56
commit
c52ea9490b
3 changed files with 91 additions and 36 deletions
|
|
@ -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]',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
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);
|
||||||
|
|
||||||
if (e.clipboardData && e.clipboardData.files.length > 0) {
|
if (e.clipboardData && e.clipboardData.files.length > 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFilesLoading(true);
|
setFilesLoading(true);
|
||||||
handleFiles(e.clipboardData.files);
|
handleFiles(e.clipboardData.files);
|
||||||
}
|
}
|
||||||
};
|
},
|
||||||
|
[handleFiles, setFilesLoading, setText],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
inputRef,
|
textAreaRef,
|
||||||
handleKeyDown,
|
handleKeyDown,
|
||||||
handleKeyUp,
|
handleKeyUp,
|
||||||
handlePaste,
|
handlePaste,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue