forked from 77media/video-flow
265 lines
7.7 KiB
TypeScript
265 lines
7.7 KiB
TypeScript
/**
|
|
* 编辑点交互组件
|
|
* 实现脉冲动画效果和点击交互
|
|
*/
|
|
|
|
import React, { useCallback, useMemo } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { Edit3, Check, X, Loader2 } from 'lucide-react';
|
|
import { EditPoint as EditPointType, EditPointStatus } from './types';
|
|
|
|
interface EditPointProps {
|
|
/** 编辑点数据 */
|
|
editPoint: EditPointType;
|
|
/** 是否被选中 */
|
|
isSelected: boolean;
|
|
/** 容器尺寸 */
|
|
containerSize: { width: number; height: number };
|
|
/** 点击事件处理 */
|
|
onClick: (editPoint: EditPointType) => void;
|
|
/** 删除事件处理 */
|
|
onDelete: (id: string) => void;
|
|
/** 编辑事件处理 */
|
|
onEdit: (id: string) => void;
|
|
/** 样式配置 */
|
|
style?: {
|
|
size?: number;
|
|
color?: string;
|
|
pulseColor?: string;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 编辑点组件
|
|
*/
|
|
export const EditPoint: React.FC<EditPointProps> = ({
|
|
editPoint,
|
|
isSelected,
|
|
containerSize,
|
|
onClick,
|
|
onDelete,
|
|
onEdit,
|
|
style = {}
|
|
}) => {
|
|
const {
|
|
size = 12,
|
|
color = '#3b82f6',
|
|
pulseColor = 'rgba(59, 130, 246, 0.3)'
|
|
} = style;
|
|
|
|
// 计算绝对位置
|
|
const absolutePosition = useMemo(() => ({
|
|
x: (editPoint.position.x / 100) * containerSize.width,
|
|
y: (editPoint.position.y / 100) * containerSize.height
|
|
}), [editPoint.position, containerSize]);
|
|
|
|
// 处理点击事件
|
|
const handleClick = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onClick(editPoint);
|
|
}, [onClick, editPoint]);
|
|
|
|
// 处理删除事件
|
|
const handleDelete = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onDelete(editPoint.id);
|
|
}, [onDelete, editPoint.id]);
|
|
|
|
// 处理编辑事件
|
|
const handleEdit = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onEdit(editPoint.id);
|
|
}, [onEdit, editPoint.id]);
|
|
|
|
// 根据状态获取颜色
|
|
const getStatusColor = useCallback(() => {
|
|
switch (editPoint.status) {
|
|
case EditPointStatus.PENDING:
|
|
return '#f59e0b'; // 黄色
|
|
case EditPointStatus.EDITED:
|
|
return '#10b981'; // 绿色
|
|
case EditPointStatus.PROCESSING:
|
|
return '#3b82f6'; // 蓝色
|
|
case EditPointStatus.COMPLETED:
|
|
return '#059669'; // 深绿色
|
|
case EditPointStatus.FAILED:
|
|
return '#ef4444'; // 红色
|
|
default:
|
|
return color;
|
|
}
|
|
}, [editPoint.status, color]);
|
|
|
|
// 根据状态获取图标
|
|
const getStatusIcon = useCallback(() => {
|
|
switch (editPoint.status) {
|
|
case EditPointStatus.PENDING:
|
|
return Edit3;
|
|
case EditPointStatus.EDITED:
|
|
return Check;
|
|
case EditPointStatus.PROCESSING:
|
|
return Loader2;
|
|
case EditPointStatus.COMPLETED:
|
|
return Check;
|
|
case EditPointStatus.FAILED:
|
|
return X;
|
|
default:
|
|
return Edit3;
|
|
}
|
|
}, [editPoint.status]);
|
|
|
|
const StatusIcon = getStatusIcon();
|
|
const statusColor = getStatusColor();
|
|
|
|
return (
|
|
<motion.div
|
|
className="absolute z-20 cursor-pointer"
|
|
data-edit-point="true"
|
|
style={{
|
|
left: absolutePosition.x - size / 2,
|
|
top: absolutePosition.y - size / 2,
|
|
}}
|
|
initial={{ scale: 0, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0, opacity: 0 }}
|
|
transition={{
|
|
type: "spring",
|
|
stiffness: 300,
|
|
damping: 25,
|
|
duration: 0.3
|
|
}}
|
|
onClick={handleClick}
|
|
>
|
|
{/* 脉冲动画背景 */}
|
|
<AnimatePresence>
|
|
{(editPoint.status === EditPointStatus.PENDING || isSelected) && (
|
|
<motion.div
|
|
className="absolute inset-0 rounded-full"
|
|
style={{
|
|
backgroundColor: pulseColor,
|
|
width: size * 3,
|
|
height: size * 3,
|
|
left: -size,
|
|
top: -size,
|
|
}}
|
|
initial={{ scale: 0, opacity: 0 }}
|
|
animate={{
|
|
scale: [1, 1.5, 1],
|
|
opacity: [0.6, 0.2, 0.6],
|
|
}}
|
|
exit={{ scale: 0, opacity: 0 }}
|
|
transition={{
|
|
duration: 2,
|
|
repeat: Infinity,
|
|
ease: "easeInOut"
|
|
}}
|
|
/>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 主编辑点 */}
|
|
<motion.div
|
|
className="relative rounded-full flex items-center justify-center shadow-lg backdrop-blur-sm"
|
|
style={{
|
|
width: size,
|
|
height: size,
|
|
backgroundColor: statusColor,
|
|
border: `2px solid rgba(255, 255, 255, 0.3)`,
|
|
}}
|
|
whileHover={{ scale: 1.2 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
animate={editPoint.status === EditPointStatus.PROCESSING ? {
|
|
rotate: 360,
|
|
transition: { duration: 1, repeat: Infinity, ease: "linear" }
|
|
} : {}}
|
|
>
|
|
<StatusIcon
|
|
size={size * 0.5}
|
|
color="white"
|
|
className={editPoint.status === EditPointStatus.PROCESSING ? "animate-spin" : ""}
|
|
/>
|
|
</motion.div>
|
|
|
|
{/* 选中状态的操作按钮 */}
|
|
<AnimatePresence>
|
|
{isSelected && editPoint.status !== EditPointStatus.PROCESSING && (
|
|
<motion.div
|
|
className="absolute flex gap-1"
|
|
style={{
|
|
left: size + 8,
|
|
top: -4,
|
|
}}
|
|
initial={{ opacity: 0, x: -10 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
exit={{ opacity: 0, x: -10 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{/* 编辑按钮 */}
|
|
<motion.button
|
|
className="w-6 h-6 rounded-full bg-blue-500/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-blue-600/80 transition-colors"
|
|
onClick={handleEdit}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
title="编辑描述"
|
|
>
|
|
<Edit3 size={12} />
|
|
</motion.button>
|
|
|
|
{/* 删除按钮 */}
|
|
<motion.button
|
|
className="w-6 h-6 rounded-full bg-red-500/80 backdrop-blur-sm flex items-center justify-center text-white hover:bg-red-600/80 transition-colors"
|
|
onClick={handleDelete}
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
title="删除编辑点"
|
|
>
|
|
<X size={12} />
|
|
</motion.button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 状态提示文本 */}
|
|
<AnimatePresence>
|
|
{isSelected && editPoint.description && (
|
|
<motion.div
|
|
className="absolute whitespace-nowrap text-xs text-white bg-black/60 backdrop-blur-sm rounded px-2 py-1 pointer-events-none"
|
|
style={{
|
|
left: size + 8,
|
|
top: size + 8,
|
|
maxWidth: 200,
|
|
}}
|
|
initial={{ opacity: 0, y: -5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -5 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{editPoint.description.length > 50
|
|
? `${editPoint.description.substring(0, 50)}...`
|
|
: editPoint.description
|
|
}
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* 时间戳显示 */}
|
|
<AnimatePresence>
|
|
{isSelected && (
|
|
<motion.div
|
|
className="absolute text-xs text-white/70 bg-black/40 backdrop-blur-sm rounded px-1 py-0.5 pointer-events-none"
|
|
style={{
|
|
left: -20,
|
|
top: size + 8,
|
|
}}
|
|
initial={{ opacity: 0, y: -5 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -5 }}
|
|
transition={{ duration: 0.2 }}
|
|
>
|
|
{Math.floor(editPoint.timestamp)}s
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
};
|