chat-input.tsx 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. import React from "react";
  2. import TextareaAutosize from "react-textarea-autosize";
  3. import ArrowSendIcon from "#/assets/arrow-send.svg?react";
  4. import { cn } from "#/utils/utils";
  5. interface ChatInputProps {
  6. name?: string;
  7. button?: "submit" | "stop";
  8. disabled?: boolean;
  9. placeholder?: string;
  10. showButton?: boolean;
  11. value?: string;
  12. maxRows?: number;
  13. onSubmit: (message: string) => void;
  14. onStop?: () => void;
  15. onChange?: (message: string) => void;
  16. onFocus?: () => void;
  17. onBlur?: () => void;
  18. onImagePaste?: (files: File[]) => void;
  19. className?: React.HTMLAttributes<HTMLDivElement>["className"];
  20. }
  21. export function ChatInput({
  22. name,
  23. button = "submit",
  24. disabled,
  25. placeholder,
  26. showButton = true,
  27. value,
  28. maxRows = 4,
  29. onSubmit,
  30. onStop,
  31. onChange,
  32. onFocus,
  33. onBlur,
  34. onImagePaste,
  35. className,
  36. }: ChatInputProps) {
  37. const textareaRef = React.useRef<HTMLTextAreaElement>(null);
  38. const [isDraggingOver, setIsDraggingOver] = React.useState(false);
  39. const handlePaste = (event: React.ClipboardEvent<HTMLTextAreaElement>) => {
  40. // Only handle paste if we have an image paste handler and there are files
  41. if (onImagePaste && event.clipboardData.files.length > 0) {
  42. const files = Array.from(event.clipboardData.files).filter((file) =>
  43. file.type.startsWith("image/"),
  44. );
  45. // Only prevent default if we found image files to handle
  46. if (files.length > 0) {
  47. event.preventDefault();
  48. onImagePaste(files);
  49. }
  50. }
  51. // For text paste, let the default behavior handle it
  52. };
  53. const handleDragOver = (event: React.DragEvent<HTMLTextAreaElement>) => {
  54. event.preventDefault();
  55. if (event.dataTransfer.types.includes("Files")) {
  56. setIsDraggingOver(true);
  57. }
  58. };
  59. const handleDragLeave = (event: React.DragEvent<HTMLTextAreaElement>) => {
  60. event.preventDefault();
  61. setIsDraggingOver(false);
  62. };
  63. const handleDrop = (event: React.DragEvent<HTMLTextAreaElement>) => {
  64. event.preventDefault();
  65. setIsDraggingOver(false);
  66. if (onImagePaste && event.dataTransfer.files.length > 0) {
  67. const files = Array.from(event.dataTransfer.files).filter((file) =>
  68. file.type.startsWith("image/"),
  69. );
  70. if (files.length > 0) {
  71. onImagePaste(files);
  72. }
  73. }
  74. };
  75. const handleSubmitMessage = () => {
  76. if (textareaRef.current?.value) {
  77. onSubmit(textareaRef.current.value);
  78. textareaRef.current.value = "";
  79. }
  80. };
  81. const handleKeyPress = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
  82. if (event.key === "Enter" && !event.shiftKey && !disabled) {
  83. event.preventDefault();
  84. handleSubmitMessage();
  85. }
  86. };
  87. const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
  88. onChange?.(event.target.value);
  89. };
  90. return (
  91. <div
  92. data-testid="chat-input"
  93. className="flex items-end justify-end grow gap-1 min-h-6"
  94. >
  95. <TextareaAutosize
  96. ref={textareaRef}
  97. name={name}
  98. placeholder={placeholder}
  99. onKeyDown={handleKeyPress}
  100. onChange={handleChange}
  101. onFocus={onFocus}
  102. onBlur={onBlur}
  103. onPaste={handlePaste}
  104. onDrop={handleDrop}
  105. onDragOver={handleDragOver}
  106. onDragLeave={handleDragLeave}
  107. value={value}
  108. minRows={1}
  109. maxRows={maxRows}
  110. data-dragging-over={isDraggingOver}
  111. className={cn(
  112. "grow text-sm self-center placeholder:text-neutral-400 text-white resize-none outline-none ring-0",
  113. "transition-all duration-200 ease-in-out",
  114. isDraggingOver
  115. ? "bg-neutral-600/50 rounded-lg px-2"
  116. : "bg-transparent",
  117. className,
  118. )}
  119. />
  120. {showButton && (
  121. <>
  122. {button === "submit" && (
  123. <button
  124. aria-label="Send"
  125. disabled={disabled}
  126. onClick={handleSubmitMessage}
  127. type="submit"
  128. className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
  129. >
  130. <ArrowSendIcon />
  131. </button>
  132. )}
  133. {button === "stop" && (
  134. <button
  135. data-testid="stop-button"
  136. aria-label="Stop"
  137. disabled={disabled}
  138. onClick={onStop}
  139. type="button"
  140. className="border border-white rounded-lg w-6 h-6 hover:bg-neutral-500 focus:bg-neutral-500 flex items-center justify-center"
  141. >
  142. <div className="w-[10px] h-[10px] bg-white" />
  143. </button>
  144. )}
  145. </>
  146. )}
  147. </div>
  148. );
  149. }