// 输入表单组件 - MD3 风格 import type { StudioKind } from '../studio/protocol/studio-agent-types'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { OutputMode, Quality, ReferenceImage } from '../types/api'; import { loadSettings } from '../lib/settings'; import { FormToolbar } from './input-form/form-toolbar'; import { ReferenceImageList } from './input-form/reference-image-list'; import { useReferenceImages } from './input-form/use-reference-images'; import { useI18n } from '../i18n'; import { ImageInputModeModal } from './ImageInputModeModal'; import { CanvasWorkspaceModal } from './canvas/CanvasWorkspaceModal'; interface InputFormProps { concept: string; onConceptChange: (value: string) => void; onSecretStudioOpen?: (studioKind: StudioKind) => void; onSubmit: (data: { concept: string; quality: Quality; outputMode: OutputMode; referenceImages?: ReferenceImage[]; }) => void; loading: boolean; } const STUDIO_KEYWORDS: Record = { hellomanim: 'manim', helloplot: 'plot', }; const TRIGGER_DELAY_MS = 1200; export function InputForm({ concept, onConceptChange, onSecretStudioOpen, onSubmit, loading }: InputFormProps) { const { t } = useI18n(); const [localError, setLocalError] = useState(null); const [quality, setQuality] = useState(loadSettings().video.quality); const [outputMode, setOutputMode] = useState('video'); const [isRecognizing, setIsRecognizing] = useState(false); const [isImageModeOpen, setIsImageModeOpen] = useState(false); const [isCanvasOpen, setIsCanvasOpen] = useState(false); const textareaRef = useRef(null); const studioKeywordTriggeredRef = useRef(false); const triggerTimerRef = useRef(null); const { images, imageError, isDragging, fileInputRef, addImages, appendImages, removeImage, handleDrop, handleDragOver, handleDragEnter, handleDragLeave, } = useReferenceImages(); const derivedError = useMemo(() => { const trimmed = concept.trim(); if (!trimmed) { return null; } if (trimmed.length < 5) { return t('form.error.minLengthShort'); } return null; }, [concept, t]); const handleSubmit = useCallback(() => { if (concept.trim().length < 5) { setLocalError(t('form.error.minLength')); textareaRef.current?.focus(); return; } setLocalError(null); onSubmit({ concept: concept.trim(), quality, outputMode, referenceImages: images.length > 0 ? images : undefined, }); }, [concept, quality, outputMode, images, onSubmit, t]); const handleTextareaKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Enter' && event.shiftKey && !loading) { event.preventDefault(); handleSubmit(); } }; // 组件卸载时清理定时器 useEffect(() => { return () => { if (triggerTimerRef.current) clearTimeout(triggerTimerRef.current); }; }, []); const handleFormSubmit = (e: React.FormEvent) => { e.preventDefault(); handleSubmit(); }; const handleOpenImageMode = useCallback(() => { setIsImageModeOpen(true); }, []); const handleCloseImageMode = useCallback(() => { setIsImageModeOpen(false); }, []); const handleImportImages = useCallback(() => { setIsImageModeOpen(false); fileInputRef.current?.click(); }, [fileInputRef]); const handleDrawMode = useCallback(() => { setIsImageModeOpen(false); setIsCanvasOpen(true); }, []); const handleCanvasComplete = useCallback((nextImages: ReferenceImage[]) => { appendImages(nextImages); setIsCanvasOpen(false); }, [appendImages]); const handleConceptChange = (value: string) => { onConceptChange(value); const normalizedConcept = value.trim().toLowerCase(); const matchedStudioKind = STUDIO_KEYWORDS[normalizedConcept]; if (matchedStudioKind && !loading) { if (!studioKeywordTriggeredRef.current) { studioKeywordTriggeredRef.current = true; setIsRecognizing(true); triggerTimerRef.current = window.setTimeout(() => { onSecretStudioOpen?.(matchedStudioKind); setIsRecognizing(false); }, TRIGGER_DELAY_MS); } return; } if (studioKeywordTriggeredRef.current && !matchedStudioKind) { studioKeywordTriggeredRef.current = false; setIsRecognizing(false); if (triggerTimerRef.current) { clearTimeout(triggerTimerRef.current); triggerTimerRef.current = null; } } setLocalError(null); }; return (