feat: add smart input switching between Input and Textarea components
• Switch to Textarea for multi-line input • Handle Shift+Enter for newlines • Maintain focus during component switch • Support multi-line paste operations • Auto-adjust textarea height dynamically
This commit is contained in:
parent
5fa92cbf99
commit
40c8f1ef7b
1 changed files with 179 additions and 58 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import Textarea from '@/components/ui/Textarea'
|
||||
import Input from '@/components/ui/Input'
|
||||
import Button from '@/components/ui/Button'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { throttle } from '@/lib/utils'
|
||||
|
|
@ -116,29 +117,24 @@ export default function RetrievalTesting() {
|
|||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [inputError, setInputError] = useState('') // Error message for input
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
|
||||
const shouldFollowScrollRef = useRef(true)
|
||||
const thinkingStartTime = useRef<number | null>(null)
|
||||
const thinkingProcessed = useRef(false)
|
||||
// Reference to track if user interaction is from the form area
|
||||
const isFormInteractionRef = useRef(false)
|
||||
// Reference to track if scroll was triggered programmatically
|
||||
const programmaticScrollRef = useRef(false)
|
||||
// Reference to track if we're currently receiving a streaming response
|
||||
const isReceivingResponseRef = useRef(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null)
|
||||
|
||||
// Add cleanup effect for memory leak prevention
|
||||
useEffect(() => {
|
||||
// Component cleanup - reset timer state to prevent memory leaks
|
||||
return () => {
|
||||
if (thinkingStartTime.current) {
|
||||
thinkingStartTime.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
// Smart switching logic: use Input for single line, Textarea for multi-line
|
||||
const hasMultipleLines = inputValue.includes('\n')
|
||||
|
||||
// Enhanced event handlers for smart switching
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
if (inputError) setInputError('')
|
||||
}, [inputError])
|
||||
|
||||
// Unified height adjustment function for textarea
|
||||
const adjustTextareaHeight = useCallback((element: HTMLTextAreaElement) => {
|
||||
requestAnimationFrame(() => {
|
||||
element.style.height = 'auto'
|
||||
element.style.height = Math.min(element.scrollHeight, 120) + 'px'
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Scroll to bottom function - restored smooth scrolling with better handling
|
||||
const scrollToBottom = useCallback(() => {
|
||||
|
|
@ -230,9 +226,11 @@ export default function RetrievalTesting() {
|
|||
setInputValue('')
|
||||
setIsLoading(true)
|
||||
|
||||
// Reset textarea height to minimum after clearing input
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '40px'
|
||||
// Reset input height to minimum after clearing input
|
||||
if (inputRef.current) {
|
||||
if ('style' in inputRef.current) {
|
||||
inputRef.current.style.height = '40px'
|
||||
}
|
||||
}
|
||||
|
||||
// Create a function to update the assistant's message
|
||||
|
|
@ -390,6 +388,111 @@ export default function RetrievalTesting() {
|
|||
[inputValue, isLoading, messages, setMessages, t, scrollToBottom]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
// Shift+Enter: Insert newline
|
||||
e.preventDefault()
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const start = target.selectionStart || 0
|
||||
const end = target.selectionEnd || 0
|
||||
const newValue = inputValue.slice(0, start) + '\n' + inputValue.slice(end)
|
||||
setInputValue(newValue)
|
||||
|
||||
// Set cursor position after the newline and adjust height if needed
|
||||
setTimeout(() => {
|
||||
if (target.setSelectionRange) {
|
||||
target.setSelectionRange(start + 1, start + 1)
|
||||
}
|
||||
|
||||
// Manually trigger height adjustment for textarea after component switch
|
||||
if (inputRef.current && inputRef.current.tagName === 'TEXTAREA') {
|
||||
adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)
|
||||
}
|
||||
}, 0)
|
||||
} else if (e.key === 'Enter' && !e.shiftKey) {
|
||||
// Enter: Submit form
|
||||
e.preventDefault()
|
||||
handleSubmit(e as any)
|
||||
}
|
||||
}, [inputValue, handleSubmit, adjustTextareaHeight])
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
// Get pasted text content
|
||||
const pastedText = e.clipboardData.getData('text')
|
||||
|
||||
// Check if it contains newlines
|
||||
if (pastedText.includes('\n')) {
|
||||
e.preventDefault() // Prevent default paste behavior
|
||||
|
||||
// Get current cursor position
|
||||
const target = e.target as HTMLInputElement | HTMLTextAreaElement
|
||||
const start = target.selectionStart || 0
|
||||
const end = target.selectionEnd || 0
|
||||
|
||||
// Build new value
|
||||
const newValue = inputValue.slice(0, start) + pastedText + inputValue.slice(end)
|
||||
|
||||
// Update state (this will trigger component switch to Textarea)
|
||||
setInputValue(newValue)
|
||||
|
||||
// Set cursor position to end of pasted content
|
||||
setTimeout(() => {
|
||||
if (inputRef.current && inputRef.current.setSelectionRange) {
|
||||
const newCursorPosition = start + pastedText.length
|
||||
inputRef.current.setSelectionRange(newCursorPosition, newCursorPosition)
|
||||
}
|
||||
}, 0)
|
||||
}
|
||||
// If no newlines, let default paste behavior continue
|
||||
}, [inputValue])
|
||||
|
||||
// Effect to handle component switching and maintain focus
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
// When component type changes, restore focus and cursor position
|
||||
const currentElement = inputRef.current
|
||||
const cursorPosition = currentElement.selectionStart || inputValue.length
|
||||
|
||||
// Use requestAnimationFrame to ensure DOM update is complete
|
||||
requestAnimationFrame(() => {
|
||||
currentElement.focus()
|
||||
if (currentElement.setSelectionRange) {
|
||||
currentElement.setSelectionRange(cursorPosition, cursorPosition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [hasMultipleLines, inputValue.length]) // Include inputValue.length dependency
|
||||
|
||||
// Effect to adjust textarea height when switching to multi-line mode
|
||||
useEffect(() => {
|
||||
if (hasMultipleLines && inputRef.current && inputRef.current.tagName === 'TEXTAREA') {
|
||||
adjustTextareaHeight(inputRef.current as HTMLTextAreaElement)
|
||||
}
|
||||
}, [hasMultipleLines, inputValue, adjustTextareaHeight])
|
||||
|
||||
// Reference to track if we should follow scroll during streaming (using ref for synchronous updates)
|
||||
const shouldFollowScrollRef = useRef(true)
|
||||
const thinkingStartTime = useRef<number | null>(null)
|
||||
const thinkingProcessed = useRef(false)
|
||||
// Reference to track if user interaction is from the form area
|
||||
const isFormInteractionRef = useRef(false)
|
||||
// Reference to track if scroll was triggered programmatically
|
||||
const programmaticScrollRef = useRef(false)
|
||||
// Reference to track if we're currently receiving a streaming response
|
||||
const isReceivingResponseRef = useRef(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Add cleanup effect for memory leak prevention
|
||||
useEffect(() => {
|
||||
// Component cleanup - reset timer state to prevent memory leaks
|
||||
return () => {
|
||||
if (thinkingStartTime.current) {
|
||||
thinkingStartTime.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Add event listeners to detect when user manually interacts with the container
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
|
|
@ -510,7 +613,16 @@ export default function RetrievalTesting() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex shrink-0 items-center gap-2" autoComplete="on">
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex shrink-0 items-center gap-2"
|
||||
autoComplete="on"
|
||||
method="post"
|
||||
action="#"
|
||||
role="search"
|
||||
>
|
||||
{/* Hidden submit button to ensure form meets HTML standards */}
|
||||
<input type="submit" style={{ display: 'none' }} tabIndex={-1} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
|
@ -525,38 +637,47 @@ export default function RetrievalTesting() {
|
|||
<label htmlFor="query-input" className="sr-only">
|
||||
{t('retrievePanel.retrieval.placeholder')}
|
||||
</label>
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
id="query-input"
|
||||
name="query"
|
||||
autoComplete="on"
|
||||
className="w-full min-h-[40px] max-h-[120px] overflow-y-auto"
|
||||
value={inputValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
if (inputError) setInputError('')
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e as any)
|
||||
}
|
||||
}}
|
||||
placeholder={t('retrievePanel.retrieval.placeholder')}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
minHeight: '40px',
|
||||
maxHeight: '120px'
|
||||
}}
|
||||
onInput={(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
target.style.height = 'auto'
|
||||
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
|
||||
}}
|
||||
/>
|
||||
{hasMultipleLines ? (
|
||||
<Textarea
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
id="query-input"
|
||||
autoComplete="on"
|
||||
className="w-full min-h-[40px] max-h-[120px] overflow-y-auto"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={t('retrievePanel.retrieval.placeholder')}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
style={{
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
minHeight: '40px',
|
||||
maxHeight: '120px'
|
||||
}}
|
||||
onInput={(e: React.FormEvent<HTMLTextAreaElement>) => {
|
||||
const target = e.target as HTMLTextAreaElement
|
||||
requestAnimationFrame(() => {
|
||||
target.style.height = 'auto'
|
||||
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
id="query-input"
|
||||
autoComplete="on"
|
||||
className="w-full"
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder={t('retrievePanel.retrieval.placeholder')}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
)}
|
||||
{/* Error message below input */}
|
||||
{inputError && (
|
||||
<div className="absolute left-0 top-full mt-1 text-xs text-red-500">{inputError}</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue