322 lines
10 KiB
TypeScript

/**
* 视频编辑覆盖层组件
* 处理视频点击事件,管理编辑点的显示和交互
*/
import React, { useCallback, useRef, useEffect, useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { EditPoint } from './EditPoint';
import { EditConnection, calculateInputPosition } from './EditConnection';
import { EditInput } from './EditInput';
import { EditDescription } from './EditDescription';
import { useVideoEdit } from './useVideoEdit';
import { EditPoint as EditPointType, EditPointPosition, EditPointStatus } from './types';
interface VideoEditOverlayProps {
/** 项目ID */
projectId: string;
/** 用户ID */
userId: number;
/** 当前视频信息 */
currentVideo?: {
id: string;
url: string;
duration: number;
} | null;
/** 视频元素引用 */
videoRef: React.RefObject<HTMLVideoElement>;
/** 是否启用编辑模式 */
enabled?: boolean;
/** 编辑描述提交回调 */
onDescriptionSubmit?: (editPoint: EditPointType, description: string) => void;
/** 编辑点变化回调 */
onEditPointsChange?: (editPoints: EditPointType[]) => void;
/** 样式类名 */
className?: string;
}
/**
* 视频编辑覆盖层组件
*/
export const VideoEditOverlay: React.FC<VideoEditOverlayProps> = ({
projectId,
userId,
currentVideo,
videoRef,
enabled = true,
onDescriptionSubmit,
onEditPointsChange,
className = ''
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 });
const [activeInputId, setActiveInputId] = useState<string | null>(null);
// 使用视频编辑Hook
const {
context,
createEditPoint,
updateEditPoint,
deleteEditPoint,
selectEditPoint,
toggleEditMode,
toggleInput,
submitDescription
} = useVideoEdit({
projectId,
userId,
currentVideo,
onEditPointsChange,
onDescriptionSubmit
});
const { editPoints, isEditMode, selectedEditPointId } = context;
// 更新容器尺寸
const updateContainerSize = useCallback(() => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect();
setContainerSize({ width: rect.width, height: rect.height });
}
}, []);
// 监听容器尺寸变化
useEffect(() => {
updateContainerSize();
const resizeObserver = new ResizeObserver(updateContainerSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [updateContainerSize]);
// 处理视频点击事件
const handleVideoClick = useCallback((e: React.MouseEvent) => {
if (!enabled || !isEditMode || !currentVideo || !containerRef.current || !videoRef.current) {
return;
}
// 检查点击目标,避免在编辑点或其子元素上创建新编辑点
const target = e.target as HTMLElement;
const isEditPointClick = target.closest('[data-edit-point]') !== null;
const isDescriptionClick = target.closest('[data-edit-description]') !== null;
const isInputClick = target.closest('[data-edit-input]') !== null;
if (isEditPointClick || isDescriptionClick || isInputClick) {
return; // 不处理编辑点相关元素的点击
}
// 阻止事件冒泡,避免触发视频播放控制
e.stopPropagation();
e.preventDefault();
const rect = containerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
// 获取当前视频时间
const currentTime = videoRef.current.currentTime || 0;
// 创建编辑点
createEditPoint({ x, y }, currentTime);
}, [enabled, isEditMode, currentVideo, createEditPoint, videoRef]);
// 处理编辑点点击
const handleEditPointClick = useCallback((editPoint: EditPointType) => {
selectEditPoint(editPoint.id);
setActiveInputId(editPoint.id);
toggleInput(editPoint.id, true);
}, [selectEditPoint, toggleInput]);
// 处理编辑点删除
const handleEditPointDelete = useCallback((id: string) => {
deleteEditPoint(id);
if (activeInputId === id) {
setActiveInputId(null);
}
}, [deleteEditPoint, activeInputId]);
// 处理编辑点编辑
const handleEditPointEdit = useCallback((id: string) => {
setActiveInputId(id);
toggleInput(id, true);
}, [toggleInput]);
// 处理描述提交
const handleDescriptionSubmit = useCallback(async (id: string, description: string) => {
const success = await submitDescription(id, description);
if (success) {
// 提交成功后隐藏输入框并清除活动状态
toggleInput(id, false);
setActiveInputId(null);
}
}, [submitDescription, toggleInput]);
// 处理输入取消
const handleInputCancel = useCallback((id: string) => {
toggleInput(id, false);
setActiveInputId(null);
// 如果是新创建的编辑点且没有描述,删除它
const editPoint = editPoints.find(point => point.id === id);
if (editPoint && !editPoint.description.trim()) {
deleteEditPoint(id);
}
}, [toggleInput, editPoints, deleteEditPoint]);
// 计算输入框和描述框位置
const elementPositions = useMemo(() => {
const positions: Record<string, any> = {};
editPoints.forEach(editPoint => {
if (containerSize.width > 0 && containerSize.height > 0) {
const absolutePosition = {
x: (editPoint.position.x / 100) * containerSize.width,
y: (editPoint.position.y / 100) * containerSize.height
};
positions[editPoint.id] = calculateInputPosition(
absolutePosition,
containerSize
);
}
});
return positions;
}, [editPoints, containerSize]);
// 键盘事件处理
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && activeInputId) {
handleInputCancel(activeInputId);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [activeInputId, handleInputCancel]);
// 如果未启用或没有当前视频,不渲染
if (!enabled || !currentVideo) {
return null;
}
return (
<div
ref={containerRef}
className={`absolute inset-0 ${className}`}
onClick={handleVideoClick}
style={{ zIndex: 15 }}
>
{/* 编辑点渲染 */}
<AnimatePresence>
{editPoints.map(editPoint => (
<EditPoint
key={editPoint.id}
editPoint={editPoint}
isSelected={selectedEditPointId === editPoint.id}
containerSize={containerSize}
onClick={handleEditPointClick}
onDelete={handleEditPointDelete}
onEdit={handleEditPointEdit}
style={context.config.pointStyle}
/>
))}
</AnimatePresence>
{/* 连接线渲染 - 仅用于输入框 */}
<AnimatePresence>
{editPoints.map(editPoint => {
const elementPosition = elementPositions[editPoint.id];
if (!editPoint.showInput || !elementPosition) return null;
const startPoint = {
x: (editPoint.position.x / 100) * containerSize.width,
y: (editPoint.position.y / 100) * containerSize.height
};
return (
<EditConnection
key={`connection-${editPoint.id}`}
startPoint={startPoint}
endPoint={elementPosition.connectionEnd}
containerSize={containerSize}
style={context.config.connectionStyle}
animated={true}
/>
);
})}
</AnimatePresence>
{/* 输入框渲染 */}
<AnimatePresence>
{editPoints.map(editPoint => {
const elementPosition = elementPositions[editPoint.id];
if (!editPoint.showInput || !elementPosition) return null;
return (
<EditInput
key={`input-${editPoint.id}`}
editPoint={editPoint}
position={{ x: elementPosition.x, y: elementPosition.y }}
isVisible={editPoint.showInput}
isSubmitting={false}
onSubmit={(description) => handleDescriptionSubmit(editPoint.id, description)}
onCancel={() => handleInputCancel(editPoint.id)}
/>
);
})}
</AnimatePresence>
{/* 已提交描述显示 */}
<AnimatePresence>
{editPoints.map(editPoint => {
const elementPosition = elementPositions[editPoint.id];
// 只显示已提交且有描述的编辑点
if (
!editPoint.description ||
editPoint.description.trim() === '' ||
editPoint.status === EditPointStatus.PENDING ||
editPoint.showInput ||
!elementPosition
) return null;
return (
<EditDescription
key={`description-${editPoint.id}`}
editPoint={editPoint}
containerSize={containerSize}
position={{ x: elementPosition.x, y: elementPosition.y }}
connectionEnd={elementPosition.connectionEnd}
onClick={(editPoint) => {
console.log('Description clicked:', editPoint);
}}
onEdit={(id) => {
setActiveInputId(id);
toggleInput(id, true);
}}
onDelete={handleEditPointDelete}
/>
);
})}
</AnimatePresence>
{/* 编辑模式提示 */}
{isEditMode && editPoints.length === 0 && (
<motion.div
className="absolute top-4 left-4 bg-black/60 backdrop-blur-sm rounded-lg px-3 py-2 text-white text-sm pointer-events-none"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.3 }}
>
Click anywhere on the video to add an edit point
</motion.div>
)}
</div>
);
};