/** * @file Input area component for composing and sending messages. * @copyright Naomi Carrigan * @license Naomi's Public License * @author Naomi Carrigan */ /* eslint-disable max-lines-per-function -- Component requires complex file handling and send logic */ /* eslint-disable max-lines -- Clipboard and file handling requires many handlers */ /* eslint-disable complexity -- Replace mode has conditional branches for initial vs follow-up state */ /* eslint-disable max-statements -- Component requires many state variables and handlers */ import { useCallback, useRef, useState, type ChangeEvent, type ClipboardEvent, type DragEvent, type JSX, type KeyboardEvent, } from "react"; import type { AspectRatio, ImageSize, Mode, PendingInput, } from "../types/index.ts"; const dropZoneBaseClass = [ "border-2 border-dashed rounded-xl p-8", "text-center cursor-pointer transition-all duration-200", ].join(" "); const dropZoneActiveClass = "border-blue-400 bg-blue-900/20"; const dropZoneInactiveClass = [ "border-purple-900/40", "hover:border-blue-500/60 hover:bg-blue-900/10", ].join(" "); const replaceButtonClass = [ "w-full bg-blue-600 hover:bg-blue-500", "disabled:bg-gray-700 disabled:cursor-not-allowed", "text-white font-semibold py-3 rounded-xl transition-all duration-200", ].join(" "); const sendButtonClass = [ "bg-gradient-to-r from-purple-600 to-pink-600", "hover:from-purple-500 hover:to-pink-500", "disabled:from-gray-700 disabled:to-gray-700 disabled:cursor-not-allowed", "text-white font-semibold py-3 px-5 rounded-xl", "transition-all duration-200 min-w-[80px]", ].join(" "); const textareaClass = [ "flex-1 bg-[#241836] border border-purple-900/40", "focus:border-purple-500/60 rounded-xl px-4 py-3", "text-white placeholder-gray-600 resize-none outline-none", "transition-colors text-sm", ].join(" "); const clearButtonClass = [ "absolute top-1 right-1 bg-black/60 hover:bg-black/80", "text-white rounded-full w-6 h-6 flex items-center", "justify-center text-xs transition-colors", ].join(" "); const pasteButtonClass = [ "w-full border border-purple-900/40 hover:border-purple-500/60", "bg-transparent hover:bg-purple-900/10 text-gray-400 hover:text-gray-200", "text-sm py-2 rounded-xl transition-all duration-200", ].join(" "); interface BuildPendingInputOptions { readonly imageBase64?: string; readonly imageMime?: string; readonly imagePreview?: string; readonly text: string; } const buildPendingInput = ({ imageBase64, imageMime, imagePreview, text, }: BuildPendingInputOptions): PendingInput | undefined => { const hasText = text.trim().length > 0; const hasImage = imageBase64 !== undefined; if (!hasText && !hasImage) { return undefined; } return { /* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */ ...(hasImage && { imageBase64, imageMime, imagePreview }), /* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */ ...(hasText && { text }), }; }; const readClipboardImage = async( onFileRead: (file: File)=> void, ): Promise => { const clipboardItems = await navigator.clipboard.read(); const imageItem = clipboardItems.find((item) => { return item.types.some((type) => { return type.startsWith("image/"); }); }); if (imageItem === undefined) { return; } const imageType = imageItem.types.find((type) => { return type.startsWith("image/"); }); if (imageType === undefined) { return; } const blob = await imageItem.getType(imageType); onFileRead(new File([ blob ], "clipboard.png", { type: imageType })); }; const modeLabelText = ( mode: Mode, aspectRatio: AspectRatio | undefined, imageSize: ImageSize, ): string => { if (mode === "avatar") { return `๐ŸŸฃ Avatar Mode ยท ${imageSize}`; } if (mode === "art") { const ratio = aspectRatio ?? "16:9"; return `๐Ÿฉท Art Mode (${ratio}) ยท ${imageSize}`; } return `๐Ÿ”ต Replace Mode ยท ${imageSize}`; }; type OnSendCallback = ( text: string, imageBase64?: string, imageMime?: string, )=> void; interface InputAreaProperties { readonly aspectRatio?: AspectRatio; readonly hasMessages: boolean; readonly imageSize: ImageSize; readonly initialImageBase64?: string; readonly initialImageMime?: string; readonly initialImagePreview?: string; readonly initialText?: string; readonly isLoading: boolean; readonly mode: Mode; readonly onInputChange?: (input: PendingInput | undefined)=> void; readonly onSend: OnSendCallback; } /** * Renders the input area for composing and sending messages. * @param props - The component props. * @param props.aspectRatio - The thread's aspect ratio setting (Art mode only). * @param props.hasMessages - Whether the thread already has messages (affects replace mode UI). * @param props.imageSize - The thread's resolution setting. * @param props.initialImageBase64 - Initial base64 image data to pre-populate. * @param props.initialImageMime - Initial image MIME type to pre-populate. * @param props.initialImagePreview - Initial image preview URL to pre-populate. * @param props.initialText - Initial draft text to pre-populate. * @param props.isLoading - Whether a message is currently being sent. * @param props.mode - The current generation mode. * @param props.onInputChange - Callback when any pending input (text or image) changes. * @param props.onSend - Callback to send a message. * @returns The JSX element. */ const inputArea = ({ aspectRatio, hasMessages, imageSize, initialImageBase64, initialImageMime, initialImagePreview, initialText, isLoading, mode, onInputChange, onSend, }: InputAreaProperties): JSX.Element => { const [ text, setText ] = useState(initialText ?? ""); const [ imageBase64, setImageBase64 ] = useState( initialImageBase64, ); const [ imageMime, setImageMime ] = useState( initialImageMime, ); const [ imagePreview, setImagePreview ] = useState( initialImagePreview, ); const [ isDragging, setIsDragging ] = useState(false); const fileInputReference = useRef(null); const handleFileRead = useCallback((file: File): void => { const reader = new FileReader(); reader.addEventListener("load", (event) => { const dataUrl = event.target?.result; if (typeof dataUrl !== "string") { return; } const [ , base64Data ] = dataUrl.split(","); if (base64Data === undefined) { return; } const mimeType = file.type.length > 0 ? file.type : "image/png"; setImageBase64(base64Data); setImageMime(mimeType); setImagePreview(dataUrl); onInputChange?.(buildPendingInput({ imageBase64: base64Data, imageMime: mimeType, imagePreview: dataUrl, text: text, })); }); reader.readAsDataURL(file); }, [ onInputChange, text ]); const handleFileChange = useCallback( (event: ChangeEvent): void => { const file = event.target.files?.[0]; if (file !== undefined) { handleFileRead(file); } }, [ handleFileRead ], ); const handleDrop = useCallback( (event: DragEvent): void => { event.preventDefault(); setIsDragging(false); const [ file ] = event.dataTransfer.files; if (file?.type.startsWith("image/") === true) { handleFileRead(file); } }, [ handleFileRead ], ); const handleDragOver = useCallback((event: DragEvent): void => { event.preventDefault(); setIsDragging(true); }, []); const handleDragLeave = useCallback((): void => { setIsDragging(false); }, []); const clearImage = useCallback((): void => { setImageBase64(undefined); setImageMime(undefined); setImagePreview(undefined); if (fileInputReference.current !== null) { fileInputReference.current.value = ""; } onInputChange?.(buildPendingInput({ text })); }, [ onInputChange, text ]); const isInitialReplace = mode === "replace" && !hasMessages; const handleSend = useCallback((): void => { if (isLoading) { return; } if (isInitialReplace && imageBase64 === undefined) { return; } const hasContent = text.trim().length > 0 || imageBase64 !== undefined; if (!isInitialReplace && !hasContent) { return; } onSend(text, imageBase64, imageMime); setText(""); setImageBase64(undefined); setImageMime(undefined); setImagePreview(undefined); onInputChange?.(undefined); }, [ isLoading, isInitialReplace, imageBase64, text, imageMime, onSend, onInputChange, ]); const handleKeyDown = useCallback( (event: KeyboardEvent): void => { if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); handleSend(); } }, [ handleSend ], ); const handleDropZoneClick = useCallback((): void => { fileInputReference.current?.click(); }, []); const handlePaste = useCallback( (event: ClipboardEvent): void => { const imageItem = [ ...event.clipboardData.items ].find((item) => { return item.type.startsWith("image/"); }); if (imageItem !== undefined) { const file = imageItem.getAsFile(); if (file !== null) { handleFileRead(file); } } }, [ handleFileRead ], ); const handlePasteButtonClick = useCallback((): void => { void readClipboardImage(handleFileRead); }, [ handleFileRead ]); const handleTextChange = useCallback( (event: ChangeEvent): void => { setText(event.target.value); onInputChange?.(buildPendingInput({ /* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */ ...(imageBase64 !== undefined && { imageBase64 }), /* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */ ...(imageMime !== undefined && { imageMime }), /* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */ ...(imagePreview !== undefined && { imagePreview }), text: event.target.value, })); }, [ onInputChange, imageBase64, imageMime, imagePreview ], ); const dropZoneClass = [ dropZoneBaseClass, isDragging ? dropZoneActiveClass : dropZoneInactiveClass, ].join(" "); const hasNoContent = text.trim().length === 0 && imageBase64 === undefined; const isSendDisabled = isLoading || hasNoContent; return (
{modeLabelText(mode, aspectRatio, imageSize)}
{isInitialReplace ?
{imagePreview === undefined ?
{"๐Ÿ“"}

{"Drop an image here, or "} {"click to browse"}

{"PNG, JPG, WEBP supported"}

:
Upload preview
}