forked from 77media/video-flow
247 lines
7.8 KiB
TypeScript
247 lines
7.8 KiB
TypeScript
/**
|
|
* 编辑输入框组件
|
|
* 实现编辑描述输入功能,支持键盘操作和动画效果
|
|
*/
|
|
|
|
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Send, X, Loader2 } from 'lucide-react';
|
|
import { EditPoint, EditPointStatus } from './types';
|
|
|
|
interface EditInputProps {
|
|
/** 编辑点数据 */
|
|
editPoint: EditPoint;
|
|
/** 输入框位置 */
|
|
position: { x: number; y: number };
|
|
/** 是否显示 */
|
|
isVisible: boolean;
|
|
/** 是否正在提交 */
|
|
isSubmitting?: boolean;
|
|
/** 提交回调 */
|
|
onSubmit: (description: string) => void;
|
|
/** 取消回调 */
|
|
onCancel: () => void;
|
|
/** 输入框尺寸 */
|
|
size?: { width: number; height: number };
|
|
/** 占位符文本 */
|
|
placeholder?: string;
|
|
}
|
|
|
|
/**
|
|
* 编辑输入框组件
|
|
*/
|
|
export const EditInput: React.FC<EditInputProps> = ({
|
|
editPoint,
|
|
position,
|
|
isVisible,
|
|
isSubmitting = false,
|
|
onSubmit,
|
|
onCancel,
|
|
size = { width: 280, height: 120 },
|
|
placeholder = "Describe your edit request..."
|
|
}) => {
|
|
const [description, setDescription] = useState(editPoint.description || '');
|
|
const [isFocused, setIsFocused] = useState(false);
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 自动聚焦
|
|
useEffect(() => {
|
|
if (isVisible && textareaRef.current) {
|
|
// 延迟聚焦,等待动画完成
|
|
const timer = setTimeout(() => {
|
|
textareaRef.current?.focus();
|
|
}, 300);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [isVisible]);
|
|
|
|
// 键盘事件处理
|
|
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
onCancel();
|
|
} else if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
|
|
e.preventDefault();
|
|
handleSubmit();
|
|
}
|
|
}, [onCancel]);
|
|
|
|
// 提交处理
|
|
const handleSubmit = useCallback(() => {
|
|
const trimmedDescription = description.trim();
|
|
if (trimmedDescription && !isSubmitting) {
|
|
onSubmit(trimmedDescription);
|
|
}
|
|
}, [description, isSubmitting, onSubmit]);
|
|
|
|
// 点击外部关闭
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
// 如果有内容,自动提交;否则取消
|
|
if (description.trim()) {
|
|
handleSubmit();
|
|
} else {
|
|
onCancel();
|
|
}
|
|
}
|
|
};
|
|
|
|
if (isVisible) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}
|
|
}, [isVisible, description, handleSubmit, onCancel]);
|
|
|
|
// 自动调整高度
|
|
const adjustHeight = useCallback(() => {
|
|
if (textareaRef.current) {
|
|
textareaRef.current.style.height = 'auto';
|
|
textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, 120)}px`;
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
adjustHeight();
|
|
}, [description, adjustHeight]);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{isVisible && (
|
|
<motion.div
|
|
ref={containerRef}
|
|
className="absolute z-30"
|
|
data-edit-input="true"
|
|
style={{
|
|
left: position.x,
|
|
top: position.y,
|
|
width: size.width,
|
|
}}
|
|
initial={{
|
|
opacity: 0,
|
|
scale: 0.8,
|
|
y: -10
|
|
}}
|
|
animate={{
|
|
opacity: 1,
|
|
scale: 1,
|
|
y: 0
|
|
}}
|
|
exit={{
|
|
opacity: 0,
|
|
scale: 0.8,
|
|
y: -10
|
|
}}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 300,
|
|
damping: 25,
|
|
duration: 0.3
|
|
}}
|
|
>
|
|
{/* 玻璃态背景容器 */}
|
|
<div className="bg-black/60 backdrop-blur-md rounded-lg border border-white/20 shadow-2xl overflow-hidden">
|
|
{/* 头部 */}
|
|
<div className="px-3 py-2 border-b border-white/10 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
|
<span className="text-xs text-white/80 font-medium">
|
|
Edit Request
|
|
</span>
|
|
<span className="text-xs text-white/50">
|
|
{Math.floor(editPoint.timestamp)}s
|
|
</span>
|
|
</div>
|
|
<button
|
|
onClick={onCancel}
|
|
className="w-5 h-5 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white/70 hover:text-white transition-colors"
|
|
disabled={isSubmitting}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
|
|
{/* 输入区域 */}
|
|
<div className="p-3">
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
onFocus={() => setIsFocused(true)}
|
|
onBlur={() => setIsFocused(false)}
|
|
placeholder={placeholder}
|
|
disabled={isSubmitting}
|
|
className="w-full bg-transparent text-white placeholder-white/40 text-sm resize-none border-none outline-none min-h-[60px] max-h-[120px]"
|
|
style={{ lineHeight: '1.4' }}
|
|
/>
|
|
</div>
|
|
|
|
{/* 底部操作区 */}
|
|
<div className="px-3 py-2 border-t border-white/10 flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-xs text-white/50">
|
|
<span>Ctrl+Enter to submit</span>
|
|
<span>•</span>
|
|
<span>Esc to cancel</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{/* 字符计数 */}
|
|
<span className={`text-xs ${
|
|
description.length > 500 ? 'text-red-400' : 'text-white/50'
|
|
}`}>
|
|
{description.length}/500
|
|
</span>
|
|
|
|
{/* 提交按钮 */}
|
|
<motion.button
|
|
onClick={handleSubmit}
|
|
disabled={!description.trim() || isSubmitting || description.length > 500}
|
|
className="flex items-center gap-1 px-3 py-1.5 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-500 disabled:cursor-not-allowed text-white text-xs rounded-md transition-colors"
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
>
|
|
{isSubmitting ? (
|
|
<>
|
|
<Loader2 size={12} className="animate-spin" />
|
|
<span>Submitting...</span>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Send size={12} />
|
|
<span>Submit</span>
|
|
</>
|
|
)}
|
|
</motion.button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 聚焦时的发光边框 */}
|
|
<AnimatePresence>
|
|
{isFocused && (
|
|
<motion.div
|
|
className="absolute inset-0 rounded-lg border-2 border-blue-400/50 pointer-events-none"
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* 输入框指示箭头(可选) */}
|
|
<div
|
|
className="absolute w-3 h-3 bg-black/60 border-l border-t border-white/20 transform rotate-45"
|
|
style={{
|
|
left: 12,
|
|
top: -6,
|
|
}}
|
|
/>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
};
|