|
| 1 | +'use client' |
| 2 | + |
| 3 | +import { useCallback, useEffect, useRef, useState } from 'react' |
| 4 | +import { ArrowUp, Mic } from 'lucide-react' |
| 5 | +import { Button } from '@/components/emcn' |
| 6 | +import { cn } from '@/lib/core/utils/cn' |
| 7 | +import { useAnimatedPlaceholder } from '../../hooks' |
| 8 | + |
| 9 | +const TEXTAREA_CLASSES = |
| 10 | + 'm-0 box-border h-auto max-h-[30vh] min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden break-words border-0 bg-transparent px-[4px] py-[4px] font-medium font-sans text-[14px] text-[var(--text-primary)] leading-[20px] outline-none placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' |
| 11 | + |
| 12 | +const SEND_BUTTON_BASE = 'h-[28px] w-[28px] rounded-full border-0 p-0 transition-colors' |
| 13 | +const SEND_BUTTON_ACTIVE = |
| 14 | + 'bg-[var(--c-383838)] hover:bg-[var(--c-575757)] dark:bg-[var(--c-E0E0E0)] dark:hover:bg-[var(--c-CFCFCF)]' |
| 15 | +const SEND_BUTTON_DISABLED = 'bg-[var(--c-808080)] dark:bg-[var(--c-808080)]' |
| 16 | + |
| 17 | +function autoResizeTextarea(e: React.FormEvent<HTMLTextAreaElement>) { |
| 18 | + const target = e.target as HTMLTextAreaElement |
| 19 | + target.style.height = 'auto' |
| 20 | + target.style.height = `${Math.min(target.scrollHeight, window.innerHeight * 0.3)}px` |
| 21 | +} |
| 22 | + |
| 23 | +interface UserInputProps { |
| 24 | + value: string |
| 25 | + onChange: (value: string) => void |
| 26 | + onSubmit: () => void |
| 27 | + isSending: boolean |
| 28 | + onStopGeneration: () => void |
| 29 | + animate?: boolean |
| 30 | +} |
| 31 | + |
| 32 | +export function UserInput({ |
| 33 | + value, |
| 34 | + onChange, |
| 35 | + onSubmit, |
| 36 | + isSending, |
| 37 | + onStopGeneration, |
| 38 | + animate = true, |
| 39 | +}: UserInputProps) { |
| 40 | + const animatedPlaceholder = useAnimatedPlaceholder() |
| 41 | + const placeholder = animate ? animatedPlaceholder : 'Send message to Sim' |
| 42 | + const canSubmit = value.trim().length > 0 && !isSending |
| 43 | + |
| 44 | + const [isListening, setIsListening] = useState(false) |
| 45 | + const recognitionRef = useRef<SpeechRecognition | null>(null) |
| 46 | + const prefixRef = useRef('') |
| 47 | + |
| 48 | + useEffect(() => { |
| 49 | + return () => { |
| 50 | + recognitionRef.current?.abort() |
| 51 | + } |
| 52 | + }, []) |
| 53 | + |
| 54 | + const textareaRef = useRef<HTMLTextAreaElement>(null) |
| 55 | + |
| 56 | + const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => { |
| 57 | + if ((e.target as HTMLElement).closest('button')) return |
| 58 | + textareaRef.current?.focus() |
| 59 | + }, []) |
| 60 | + |
| 61 | + const handleKeyDown = useCallback( |
| 62 | + (e: React.KeyboardEvent<HTMLTextAreaElement>) => { |
| 63 | + if (e.key === 'Enter' && !e.shiftKey) { |
| 64 | + e.preventDefault() |
| 65 | + onSubmit() |
| 66 | + } |
| 67 | + }, |
| 68 | + [onSubmit] |
| 69 | + ) |
| 70 | + |
| 71 | + const toggleListening = useCallback(() => { |
| 72 | + if (isListening) { |
| 73 | + recognitionRef.current?.stop() |
| 74 | + recognitionRef.current = null |
| 75 | + setIsListening(false) |
| 76 | + return |
| 77 | + } |
| 78 | + |
| 79 | + const w = window as Window & { |
| 80 | + SpeechRecognition?: typeof SpeechRecognition |
| 81 | + webkitSpeechRecognition?: typeof SpeechRecognition |
| 82 | + } |
| 83 | + const SpeechRecognitionAPI = w.SpeechRecognition || w.webkitSpeechRecognition |
| 84 | + if (!SpeechRecognitionAPI) return |
| 85 | + |
| 86 | + prefixRef.current = value |
| 87 | + |
| 88 | + const recognition = new SpeechRecognitionAPI() |
| 89 | + recognition.continuous = true |
| 90 | + recognition.interimResults = true |
| 91 | + recognition.lang = 'en-US' |
| 92 | + |
| 93 | + recognition.onresult = (event: SpeechRecognitionEvent) => { |
| 94 | + let transcript = '' |
| 95 | + for (let i = 0; i < event.results.length; i++) { |
| 96 | + transcript += event.results[i][0].transcript |
| 97 | + } |
| 98 | + const prefix = prefixRef.current |
| 99 | + onChange(prefix ? `${prefix} ${transcript}` : transcript) |
| 100 | + } |
| 101 | + |
| 102 | + recognition.onend = () => { |
| 103 | + if (recognitionRef.current === recognition) { |
| 104 | + try { |
| 105 | + recognition.start() |
| 106 | + } catch { |
| 107 | + recognitionRef.current = null |
| 108 | + setIsListening(false) |
| 109 | + } |
| 110 | + } |
| 111 | + } |
| 112 | + recognition.onerror = (e: SpeechRecognitionErrorEvent) => { |
| 113 | + if (e.error === 'aborted' || e.error === 'not-allowed') { |
| 114 | + recognitionRef.current = null |
| 115 | + setIsListening(false) |
| 116 | + } |
| 117 | + } |
| 118 | + |
| 119 | + recognitionRef.current = recognition |
| 120 | + recognition.start() |
| 121 | + setIsListening(true) |
| 122 | + }, [isListening, value, onChange]) |
| 123 | + |
| 124 | + return ( |
| 125 | + <div |
| 126 | + onClick={handleContainerClick} |
| 127 | + className='mx-auto w-full max-w-[640px] cursor-text rounded-[12px] border border-[var(--border-1)] bg-white px-[10px] py-[8px] shadow-sm dark:bg-[var(--surface-4)]' |
| 128 | + > |
| 129 | + <textarea |
| 130 | + ref={textareaRef} |
| 131 | + value={value} |
| 132 | + onChange={(e) => onChange(e.target.value)} |
| 133 | + onKeyDown={handleKeyDown} |
| 134 | + onInput={autoResizeTextarea} |
| 135 | + placeholder={placeholder} |
| 136 | + rows={1} |
| 137 | + className={TEXTAREA_CLASSES} |
| 138 | + /> |
| 139 | + <div className='flex items-center justify-end gap-[6px]'> |
| 140 | + <button |
| 141 | + type='button' |
| 142 | + onClick={toggleListening} |
| 143 | + className={cn( |
| 144 | + 'flex h-[28px] w-[28px] items-center justify-center rounded-full transition-colors', |
| 145 | + isListening |
| 146 | + ? 'bg-red-500 text-white hover:bg-red-600' |
| 147 | + : 'text-[var(--text-muted)] hover:text-[var(--text-primary)]' |
| 148 | + )} |
| 149 | + title={isListening ? 'Stop listening' : 'Voice input'} |
| 150 | + > |
| 151 | + <Mic className='h-[16px] w-[16px]' strokeWidth={2} /> |
| 152 | + </button> |
| 153 | + {isSending ? ( |
| 154 | + <Button |
| 155 | + onClick={onStopGeneration} |
| 156 | + className={cn(SEND_BUTTON_BASE, SEND_BUTTON_ACTIVE)} |
| 157 | + title='Stop generation' |
| 158 | + > |
| 159 | + <svg |
| 160 | + className='block h-[14px] w-[14px] fill-white dark:fill-black' |
| 161 | + viewBox='0 0 24 24' |
| 162 | + xmlns='http://www.w3.org/2000/svg' |
| 163 | + > |
| 164 | + <rect x='4' y='4' width='16' height='16' rx='3' ry='3' /> |
| 165 | + </svg> |
| 166 | + </Button> |
| 167 | + ) : ( |
| 168 | + <Button |
| 169 | + onClick={onSubmit} |
| 170 | + disabled={!canSubmit} |
| 171 | + className={cn(SEND_BUTTON_BASE, canSubmit ? SEND_BUTTON_ACTIVE : SEND_BUTTON_DISABLED)} |
| 172 | + > |
| 173 | + <ArrowUp |
| 174 | + className='block h-[16px] w-[16px] text-white dark:text-black' |
| 175 | + strokeWidth={2.25} |
| 176 | + /> |
| 177 | + </Button> |
| 178 | + )} |
| 179 | + </div> |
| 180 | + </div> |
| 181 | + ) |
| 182 | +} |
0 commit comments