forked from 77media/video-flow
优化chatbox输入框
This commit is contained in:
parent
5fe8c76efa
commit
daa38b5991
@ -1,7 +1,22 @@
|
|||||||
import React, { useRef, useState, useEffect } from "react";
|
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||||
import { Image as ImageIcon, Send, Trash2, ArrowUp } from "lucide-react";
|
import { Image as ImageIcon, Send, Trash2, ArrowUp } from "lucide-react";
|
||||||
import { MessageBlock } from "./types";
|
import { MessageBlock } from "./types";
|
||||||
import { useUploadFile } from "@/app/service/domain/service";
|
import { useUploadFile } from "@/app/service/domain/service";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
|
// 防抖函数
|
||||||
|
function debounce<T extends (...args: any[]) => void>(func: T, wait: number) {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
return function executedFunction(...args: Parameters<T>) {
|
||||||
|
if (timeout) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
func(...args);
|
||||||
|
timeout = null;
|
||||||
|
}, wait);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
interface InputBarProps {
|
interface InputBarProps {
|
||||||
onSend: (blocks: MessageBlock[], videoId?: string) => void;
|
onSend: (blocks: MessageBlock[], videoId?: string) => void;
|
||||||
@ -21,18 +36,47 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
|
|||||||
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const { uploadFile } = useUploadFile();
|
const { uploadFile } = useUploadFile();
|
||||||
|
const [isComposing, setIsComposing] = useState(false);
|
||||||
|
|
||||||
const adjustHeight = () => {
|
const adjustHeight = useCallback(() => {
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (textarea) {
|
if (!textarea || isComposing) return;
|
||||||
textarea.style.height = 'auto';
|
|
||||||
const newHeight = Math.min(Math.max(textarea.scrollHeight, 48), 120);
|
// 保存当前滚动位置
|
||||||
textarea.style.height = `${newHeight}px`;
|
const scrollPos = window.scrollY;
|
||||||
|
|
||||||
// 检查是否超过一行(48px 是单行高度)
|
// 重置高度以获取实际内容高度
|
||||||
setIsMultiline(newHeight > 48);
|
textarea.style.height = '0px';
|
||||||
|
|
||||||
|
// 强制浏览器重排,获取准确的 scrollHeight
|
||||||
|
const scrollHeight = textarea.scrollHeight;
|
||||||
|
const newHeight = Math.min(Math.max(scrollHeight, 48), 120);
|
||||||
|
|
||||||
|
// 设置新高度
|
||||||
|
textarea.style.height = `${newHeight}px`;
|
||||||
|
|
||||||
|
// 恢复滚动位置,避免页面跳动
|
||||||
|
window.scrollTo(0, scrollPos);
|
||||||
|
|
||||||
|
// 更新布局状态
|
||||||
|
if (!text.trim()) {
|
||||||
|
setIsMultiline(false);
|
||||||
|
} else if (!isMultiline && newHeight > 48) {
|
||||||
|
setIsMultiline(true);
|
||||||
}
|
}
|
||||||
};
|
}, [text, isMultiline, isComposing]);
|
||||||
|
|
||||||
|
// 使用防抖包装 adjustHeight,但确保在输入完成时立即调整
|
||||||
|
const debouncedAdjustHeight = useCallback(
|
||||||
|
debounce(() => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!isComposing) {
|
||||||
|
adjustHeight();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 16), // 使用一帧的时间作为防抖时间
|
||||||
|
[adjustHeight, isComposing]
|
||||||
|
);
|
||||||
|
|
||||||
// 监听初始视频 URL 和 ID 的变化
|
// 监听初始视频 URL 和 ID 的变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -44,8 +88,8 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
|
|||||||
|
|
||||||
// 监听文本变化和组件挂载时调整高度
|
// 监听文本变化和组件挂载时调整高度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adjustHeight();
|
debouncedAdjustHeight();
|
||||||
}, [text]);
|
}, [text, debouncedAdjustHeight]);
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
const blocks: MessageBlock[] = [];
|
const blocks: MessageBlock[] = [];
|
||||||
@ -172,81 +216,117 @@ export function InputBar({ onSend, setVideoPreview, initialVideoUrl, initialVide
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={`${isMultiline ? 'flex flex-col' : 'flex items-center'} gap-2 px-3 m-3 border border-gray-700 rounded-[2rem]`}>
|
<motion.div
|
||||||
{/* 图片上传按钮 - 单行时显示在左侧,多行时显示在底部 */}
|
layout
|
||||||
{!isMultiline && (
|
className="px-3 m-3 border border-gray-700 rounded-[2rem]"
|
||||||
<label
|
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||||
className={`cursor-pointer inline-flex items-center gap-2 p-2 my-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
>
|
||||||
data-alt="file-upload"
|
<motion.div
|
||||||
>
|
layout
|
||||||
<ImageIcon size={16} />
|
className={`${isMultiline ? 'flex flex-col' : 'flex items-center'} gap-2`}
|
||||||
<input
|
>
|
||||||
className="hidden"
|
<AnimatePresence mode="popLayout">
|
||||||
type="file"
|
{/* 图片上传按钮 - 单行时显示在左侧 */}
|
||||||
accept="image/*"
|
{!isMultiline && (
|
||||||
onChange={onFileChange}
|
<motion.label
|
||||||
disabled={isUploading}
|
initial={{ opacity: 0, scale: 0.8 }}
|
||||||
/>
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
</label>
|
exit={{ opacity: 0, scale: 0.8 }}
|
||||||
)}
|
className={`cursor-pointer inline-flex items-center gap-2 p-2 my-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
data-alt="file-upload"
|
||||||
|
>
|
||||||
|
<ImageIcon size={16} />
|
||||||
|
<input
|
||||||
|
className="hidden"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={onFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</motion.label>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 文本输入 */}
|
{/* 文本输入 */}
|
||||||
<textarea
|
<motion.div layout className="flex-1">
|
||||||
ref={textareaRef}
|
<textarea
|
||||||
placeholder="输入文字…"
|
ref={textareaRef}
|
||||||
className="w-full pl-[10px] pr-[10px] py-[14px] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
placeholder="输入文字…"
|
||||||
rows={1}
|
className="w-full pl-[10px] pr-[10px] py-[1rem] rounded-[10px] leading-[20px] text-sm border-none bg-transparent text-white placeholder:text-white/[0.40] focus:outline-none resize-none min-h-[48px] max-h-[120px] overflow-y-auto"
|
||||||
value={text}
|
rows={1}
|
||||||
onChange={(e) => setText(e.target.value)}
|
value={text}
|
||||||
onKeyDown={(e) => {
|
onChange={(e) => setText(e.target.value)}
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
onCompositionStart={() => setIsComposing(true)}
|
||||||
e.preventDefault();
|
onCompositionEnd={() => {
|
||||||
handleSend();
|
setIsComposing(false);
|
||||||
}
|
// 输入法完成后等待下一帧再调整高度,确保文本内容已更新
|
||||||
}}
|
requestAnimationFrame(() => {
|
||||||
data-alt="text-input"
|
requestAnimationFrame(adjustHeight);
|
||||||
/>
|
});
|
||||||
|
}}
|
||||||
{isMultiline ? (
|
onKeyDown={(e) => {
|
||||||
// 多行模式:底部按钮区域
|
if (e.key === "Enter" && !e.shiftKey && !isComposing) {
|
||||||
<div className="flex justify-between items-center pb-2">
|
e.preventDefault();
|
||||||
{/* 图片上传 */}
|
handleSend();
|
||||||
<label
|
}
|
||||||
className={`cursor-pointer inline-flex items-center gap-2 p-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
}}
|
||||||
data-alt="file-upload"
|
data-alt="text-input"
|
||||||
>
|
|
||||||
<ImageIcon size={16} />
|
|
||||||
<input
|
|
||||||
className="hidden"
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
onChange={onFileChange}
|
|
||||||
disabled={isUploading}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</motion.div>
|
||||||
|
|
||||||
{/* 发送按钮 必须有文字 */}
|
{isMultiline ? (
|
||||||
<button
|
// 多行模式:底部按钮区域
|
||||||
onClick={handleSend}
|
<motion.div
|
||||||
className="inline-flex items-center gap-2 p-2 rounded-full bg-[#f1f3f4] text-[#25294b] shadow disabled:text-white/25 disabled:border disabled:border-white/10 disabled:bg-[#1b1b1b80] disabled:cursor-not-allowed"
|
layout
|
||||||
data-alt="send-button"
|
initial={{ opacity: 0, y: -10 }}
|
||||||
disabled={!text.trim()}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
>
|
exit={{ opacity: 0, y: -10 }}
|
||||||
<ArrowUp size={18} />
|
className="flex justify-between items-center w-full pb-2"
|
||||||
</button>
|
>
|
||||||
</div>
|
{/* 图片上传 */}
|
||||||
) : (
|
<motion.label
|
||||||
// 单行模式:发送按钮
|
whileHover={{ scale: 1.05 }}
|
||||||
<button
|
whileTap={{ scale: 0.95 }}
|
||||||
onClick={handleSend}
|
className={`cursor-pointer inline-flex items-center gap-2 p-2 rounded-full hover:bg-gray-700/50 text-gray-100 ${isUploading ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
className="inline-flex items-center gap-2 p-2 my-2 rounded-full bg-[#f1f3f4] text-[#25294b] shadow disabled:text-white/25 disabled:border disabled:border-white/10 disabled:bg-[#1b1b1b80] disabled:cursor-not-allowed"
|
data-alt="file-upload"
|
||||||
data-alt="send-button"
|
>
|
||||||
disabled={!text.trim()}
|
<ImageIcon size={16} />
|
||||||
>
|
<input
|
||||||
<ArrowUp size={18} />
|
className="hidden"
|
||||||
</button>
|
type="file"
|
||||||
)}
|
accept="image/*"
|
||||||
</div>
|
onChange={onFileChange}
|
||||||
|
disabled={isUploading}
|
||||||
|
/>
|
||||||
|
</motion.label>
|
||||||
|
|
||||||
|
{/* 发送按钮 必须有文字 */}
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={handleSend}
|
||||||
|
className="inline-flex items-center gap-2 p-2 rounded-full bg-[#f1f3f4] text-[#25294b] shadow disabled:text-white/25 disabled:border disabled:border-white/10 disabled:bg-[#1b1b1b80] disabled:cursor-not-allowed"
|
||||||
|
data-alt="send-button"
|
||||||
|
disabled={!text.trim()}
|
||||||
|
>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
// 单行模式:发送按钮
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={handleSend}
|
||||||
|
className="inline-flex items-center gap-2 p-2 my-2 rounded-full bg-[#f1f3f4] text-[#25294b] shadow disabled:text-white/25 disabled:border disabled:border-white/10 disabled:bg-[#1b1b1b80] disabled:cursor-not-allowed"
|
||||||
|
data-alt="send-button"
|
||||||
|
disabled={!text.trim()}
|
||||||
|
>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -38,7 +38,7 @@ const stageIconMap = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_COLORS = ['#FF5733', '#126821', '#8d3913', '#FF33A1', '#A133FF', '#FF3333', '#3333FF', '#A1A1A1', '#a1115e', '#30527f'];
|
const TAG_COLORS = ['#126821', '#A133FF', '#3333FF', '#a1115e'];
|
||||||
|
|
||||||
// 阶段图标组件
|
// 阶段图标组件
|
||||||
const StageIcons = ({ currentStage, isExpanded, isPauseWorkFlow }: { currentStage: number, isExpanded: boolean, isPauseWorkFlow: boolean }) => {
|
const StageIcons = ({ currentStage, isExpanded, isPauseWorkFlow }: { currentStage: number, isExpanded: boolean, isPauseWorkFlow: boolean }) => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user