mirror of
https://github.com/stackblitz-labs/bolt.diy
synced 2025-06-26 18:26:38 +00:00
207 lines
6.2 KiB
TypeScript
207 lines
6.2 KiB
TypeScript
import React from 'react';
|
|
import { ClientOnly } from 'remix-utils/client-only';
|
|
import { IconButton } from '~/components/ui/IconButton';
|
|
import { classNames } from '~/utils/classNames';
|
|
import { SendButton } from '~/components/chat/SendButton.client';
|
|
import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition';
|
|
|
|
export interface MessageInputProps {
|
|
textareaRef?: React.RefObject<HTMLTextAreaElement>;
|
|
input?: string;
|
|
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
|
handleSendMessage?: (event: React.UIEvent) => void;
|
|
handleStop?: () => void;
|
|
hasPendingMessage?: boolean;
|
|
chatStarted?: boolean;
|
|
uploadedFiles?: File[];
|
|
setUploadedFiles?: (files: File[]) => void;
|
|
imageDataList?: string[];
|
|
setImageDataList?: (dataList: string[]) => void;
|
|
isListening?: boolean;
|
|
onStartListening?: () => void;
|
|
onStopListening?: () => void;
|
|
minHeight?: number;
|
|
maxHeight?: number;
|
|
}
|
|
|
|
export const MessageInput: React.FC<MessageInputProps> = ({
|
|
textareaRef,
|
|
input = '',
|
|
handleInputChange = () => {},
|
|
handleSendMessage = () => {},
|
|
handleStop = () => {},
|
|
hasPendingMessage = false,
|
|
chatStarted = false,
|
|
uploadedFiles = [],
|
|
setUploadedFiles = () => {},
|
|
imageDataList = [],
|
|
setImageDataList = () => {},
|
|
isListening = false,
|
|
onStartListening = () => {},
|
|
onStopListening = () => {},
|
|
minHeight = 76,
|
|
maxHeight = 200,
|
|
}) => {
|
|
const handleFileUpload = () => {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'image/*';
|
|
|
|
input.onchange = async (e) => {
|
|
const file = (e.target as HTMLInputElement).files?.[0];
|
|
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = (e) => {
|
|
const base64Image = e.target?.result as string;
|
|
setUploadedFiles([...uploadedFiles, file]);
|
|
setImageDataList([...imageDataList, base64Image]);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
};
|
|
|
|
input.click();
|
|
};
|
|
|
|
const handlePaste = async (e: React.ClipboardEvent) => {
|
|
const items = e.clipboardData?.items;
|
|
|
|
if (!items) {
|
|
return;
|
|
}
|
|
|
|
for (const item of items) {
|
|
if (item.type.startsWith('image/')) {
|
|
e.preventDefault();
|
|
|
|
const file = item.getAsFile();
|
|
|
|
if (file) {
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = (e) => {
|
|
const base64Image = e.target?.result as string;
|
|
setUploadedFiles([...uploadedFiles, file]);
|
|
setImageDataList([...imageDataList, base64Image]);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={classNames('relative shadow-xs border border-bolt-elements-borderColor backdrop-blur rounded-lg')}>
|
|
<textarea
|
|
ref={textareaRef}
|
|
className={classNames(
|
|
'w-full pl-4 pt-4 pr-25 outline-none resize-none text-bolt-elements-textPrimary placeholder-bolt-elements-textTertiary bg-transparent text-sm',
|
|
'transition-all duration-200',
|
|
'hover:border-bolt-elements-focus',
|
|
)}
|
|
onDragEnter={(e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.style.border = '2px solid #1488fc';
|
|
}}
|
|
onDragOver={(e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.style.border = '2px solid #1488fc';
|
|
}}
|
|
onDragLeave={(e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
e.currentTarget.style.border = '1px solid var(--bolt-elements-borderColor)';
|
|
|
|
const files = Array.from(e.dataTransfer.files);
|
|
files.forEach((file) => {
|
|
if (file.type.startsWith('image/')) {
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = (e) => {
|
|
const base64Image = e.target?.result as string;
|
|
setUploadedFiles([...uploadedFiles, file]);
|
|
setImageDataList([...imageDataList, base64Image]);
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
});
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Enter') {
|
|
if (event.shiftKey) {
|
|
return;
|
|
}
|
|
|
|
event.preventDefault();
|
|
|
|
if (hasPendingMessage) {
|
|
handleStop();
|
|
return;
|
|
}
|
|
|
|
if (event.nativeEvent.isComposing) {
|
|
return;
|
|
}
|
|
|
|
handleSendMessage(event);
|
|
}
|
|
}}
|
|
value={input}
|
|
onChange={handleInputChange}
|
|
onPaste={handlePaste}
|
|
style={{
|
|
minHeight,
|
|
maxHeight,
|
|
}}
|
|
placeholder={chatStarted ? 'How can we help you?' : 'What do you want to build?'}
|
|
translate="no"
|
|
/>
|
|
<ClientOnly>
|
|
{() => (
|
|
<SendButton
|
|
show={(hasPendingMessage || input.length > 0 || uploadedFiles.length > 0) && chatStarted}
|
|
hasPendingMessage={hasPendingMessage}
|
|
onClick={(event) => {
|
|
if (hasPendingMessage) {
|
|
handleStop();
|
|
return;
|
|
}
|
|
|
|
if (input.length > 0 || uploadedFiles.length > 0) {
|
|
handleSendMessage(event);
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</ClientOnly>
|
|
<div className="flex justify-between items-center text-sm p-4 pt-2">
|
|
<div className="flex gap-1 items-center">
|
|
<IconButton title="Upload file" className="transition-all" onClick={handleFileUpload}>
|
|
<div className="i-ph:paperclip text-xl"></div>
|
|
</IconButton>
|
|
|
|
<SpeechRecognitionButton
|
|
isListening={isListening}
|
|
onStart={onStartListening}
|
|
onStop={onStopListening}
|
|
disabled={hasPendingMessage}
|
|
/>
|
|
</div>
|
|
{input.length > 3 ? (
|
|
<div className="text-xs text-bolt-elements-textTertiary">
|
|
Use <kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Shift</kbd> +{' '}
|
|
<kbd className="kdb px-1.5 py-0.5 rounded bg-bolt-elements-background-depth-2">Return</kbd> a new line
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|