From 40c8f1ef7b18b1a7d4f6e2a44f5d907f9ff053b4 Mon Sep 17 00:00:00 2001 From: yangdx Date: Mon, 22 Sep 2025 13:00:22 +0800 Subject: [PATCH] feat: add smart input switching between Input and Textarea components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • 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 --- .../src/features/RetrievalTesting.tsx | 237 +++++++++++++----- 1 file changed, 179 insertions(+), 58 deletions(-) diff --git a/lightrag_webui/src/features/RetrievalTesting.tsx b/lightrag_webui/src/features/RetrievalTesting.tsx index 6233b5f8..f9f7edf3 100644 --- a/lightrag_webui/src/features/RetrievalTesting.tsx +++ b/lightrag_webui/src/features/RetrievalTesting.tsx @@ -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(null) - // Reference to track if we should follow scroll during streaming (using ref for synchronous updates) - const shouldFollowScrollRef = useRef(true) - const thinkingStartTime = useRef(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(null) - const messagesContainerRef = useRef(null) + const inputRef = useRef(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) => { + 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) => { + 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) => { + // 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(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(null) + const messagesContainerRef = useRef(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() { -
+ + {/* Hidden submit button to ensure form meets HTML standards */} +