forked from 77media/video-flow
322 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|