feat: 添加连线题和选择题功能,增强互动性

- 在多个组件中引入 MatchQuizPanel 和 SingleChoiceQuizPanel,提升用户参与度。
- 更新 HuangyanpeiPlace1、HuangyanpeiPlace3、LuxunPlace1、LuxunPlace2 组件,增加新的选择题和连线题,丰富学习内容。
- 调整成功提示逻辑,确保用户体验流畅。
- 优化视频播放中的锚点逻辑,提升互动性和学习效果。
This commit is contained in:
zhongyidian committed 2026-02-02 15:41:49 +08:00
1 parent 02a3f00a9b
commit 966b47ede3
12 files changed
+1128 -42

No files matched your search

@@ -0,0 +1,337 @@
import { useState, useEffect, useRef } from "react";
import { cn } from "@/lib/utils";
import type { MatchQuizPanelProps } from "./types";
/**
* 连线题面板组件
* 支持移动端触摸操作
*
* @example
* ```tsx
* <MatchQuizPanel
* visible={true}
* title="请将日期与对应的事件连线"
* leftItems={[
* { id: "1", label: "1892-1898年" },
* { id: "2", label: "1919-1923年" },
* ]}
* rightItems={[
* { id: "a", label: "在绍兴三味书屋求学积淀" },
* { id: "b", label: "于北京八道湾参加新文化运动" },
* ]}
* correctMatches={[
* { leftId: "1", rightId: "a" },
* { leftId: "2", rightId: "b" },
* ]}
* onSubmit={(matches, isCorrect) => {
* console.log("匹配结果:", matches, "是否正确:", isCorrect);
* }}
* />
* ```
*/
const MatchQuizPanel = ({
visible,
zIndex = 10000,
title,
leftItems,
rightItems,
correctMatches,
errorMessage,
onSubmit,
onError,
disabled = false,
}: MatchQuizPanelProps) => {
const [matches, setMatches] = useState<Array<{ leftId: string; rightId: string }>>([]);
const [selectedLeftId, setSelectedLeftId] = useState<string | null>(null);
const [error, setError] = useState<string>("");
const containerRef = useRef<HTMLDivElement>(null);
// 当窗口关闭时,重置状态
useEffect(() => {
if (!visible) {
setMatches([]);
setSelectedLeftId(null);
setError("");
}
}, [visible]);
// 处理左列项点击
const handleLeftItemClick = (leftId: string) => {
if (disabled) return;
// 如果已经匹配过,先取消匹配
const existingMatch = matches.find((m) => m.leftId === leftId);
if (existingMatch) {
setMatches(matches.filter((m) => m.leftId !== leftId));
setSelectedLeftId(null);
setError("");
return;
}
// 如果已经选中了另一个左项,取消之前的选中
if (selectedLeftId && selectedLeftId !== leftId) {
setSelectedLeftId(leftId);
setError("");
return;
}
// 切换选中状态
setSelectedLeftId(selectedLeftId === leftId ? null : leftId);
setError("");
};
// 处理右列项点击
const handleRightItemClick = (rightId: string) => {
if (disabled) return;
// 如果没有选中左项,直接返回
if (!selectedLeftId) return;
// 如果已经匹配过,先取消匹配
const existingMatch = matches.find((m) => m.rightId === rightId);
if (existingMatch) {
setMatches(matches.filter((m) => m.rightId !== rightId));
setSelectedLeftId(null);
setError("");
return;
}
// 创建新的匹配
const newMatch = { leftId: selectedLeftId, rightId };
const newMatches = [...matches, newMatch];
setMatches(newMatches);
setSelectedLeftId(null);
setError("");
// 检查是否所有项都已匹配
if (newMatches.length === leftItems.length && newMatches.length === rightItems.length) {
// 验证答案
const isCorrect = validateMatches(newMatches);
if (isCorrect) {
onSubmit?.(newMatches, true);
} else {
const errorMsg = errorMessage || "连线不正确,请重新尝试!";
setError(errorMsg);
onError?.(errorMsg);
// 清空匹配,允许重新尝试
setTimeout(() => {
setMatches([]);
}, 2000);
}
}
};
// 验证匹配是否正确
const validateMatches = (matchesToValidate: Array<{ leftId: string; rightId: string }>) => {
if (!correctMatches || matchesToValidate.length !== correctMatches.length) {
return false;
}
// 检查每个匹配是否正确
for (const match of matchesToValidate) {
const isCorrect = correctMatches.some(
(cm) => cm.leftId === match.leftId && cm.rightId === match.rightId
);
if (!isCorrect) {
return false;
}
}
return true;
};
// 获取左项是否已匹配
const isLeftItemMatched = (leftId: string) => {
return matches.some((m) => m.leftId === leftId);
};
// 获取右项是否已匹配
const isRightItemMatched = (rightId: string) => {
return matches.some((m) => m.rightId === rightId);
};
// 获取左项对应的右项ID
const getMatchedRightId = (leftId: string) => {
const match = matches.find((m) => m.leftId === leftId);
return match?.rightId || null;
};
// 获取右项对应的左项ID
const getMatchedLeftId = (rightId: string) => {
const match = matches.find((m) => m.rightId === rightId);
return match?.leftId || null;
};
if (!visible) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center bg-black/40"
style={{ zIndex }}
ref={containerRef}
>
<div className="relative w-full max-w-[1728px] h-full max-h-[972px] flex flex-col items-center justify-center p-4 md:p-8">
{/* 题干卡片 */}
<div className="w-full max-w-[1700px] rounded-[32px] bg-black/90 px-6 md:px-20 py-6 md:py-8 shadow-[0_20px_60px_rgba(240,190,117,0.8)] border-4 border-[#d4c5a9] mb-4 md:mb-8">
<div className="text-[24px] md:text-[34px] leading-relaxed text-white text-center">
{title}
</div>
{/* 答案反馈 */}
{error && (
<div className="mt-4 md:mt-5 text-center text-[20px] md:text-[26px] text-[#fcbe53]">
{error}
</div>
)}
</div>
{/* 连线区域 */}
<div className="w-full max-w-[1600px] flex flex-col md:flex-row gap-4 md:gap-8 items-start md:items-center justify-center">
{/* 左列:日期 */}
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
{leftItems.map((item) => {
const isMatched = isLeftItemMatched(item.id);
const isSelected = selectedLeftId === item.id;
const matchedRightId = getMatchedRightId(item.id);
return (
<div
key={item.id}
className={cn(
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200",
"text-[20px] md:text-[28px] leading-relaxed cursor-pointer",
"touch-manipulation", // 优化移动端触摸
disabled || isMatched
? "opacity-60 cursor-not-allowed"
: "cursor-pointer",
isSelected
? "bg-[#d4a574] text-[#5c3e2e] border-[#f0be75] shadow-lg scale-105"
: isMatched
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
: "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
)}
onClick={() => handleLeftItemClick(item.id)}
onTouchStart={(e) => {
// 移动端触摸优化
e.currentTarget.classList.add("scale-105");
}}
onTouchEnd={(e) => {
e.currentTarget.classList.remove("scale-105");
}}
>
{item.label}
{isMatched && (
<div className="absolute -right-2 md:-right-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs md:text-sm">✓</span>
</div>
)}
</div>
);
})}
</div>
{/* 中间连线区域(移动端隐藏,桌面端显示) */}
<div className="hidden md:block w-16 h-full min-h-[300px] relative flex-shrink-0">
{/* 绘制连线 */}
<svg className="absolute inset-0 w-full h-full pointer-events-none overflow-visible">
{matches.map((match, index) => {
const leftItem = leftItems.find((item) => item.id === match.leftId);
const rightItem = rightItems.find((item) => item.id === match.rightId);
if (!leftItem || !rightItem) return null;
const leftIndex = leftItems.findIndex((item) => item.id === match.leftId);
const rightIndex = rightItems.findIndex((item) => item.id === match.rightId);
// 计算连线起点和终点(基于项目高度和间距)
const itemHeight = 80; // 每个项目的大概高度
const gap = 16; // 项目之间的间距
const startY = leftIndex * (itemHeight + gap) + itemHeight / 2;
const endY = rightIndex * (itemHeight + gap) + itemHeight / 2;
return (
<line
key={`${match.leftId}-${match.rightId}-${index}`}
x1="0"
y1={startY}
x2="100%"
y2={endY}
stroke="#f0be75"
strokeWidth="3"
strokeDasharray="5,5"
markerEnd="url(#arrowhead)"
/>
);
})}
{/* 箭头标记定义 */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="10"
refX="9"
refY="3"
orient="auto"
>
<polygon points="0 0, 10 3, 0 6" fill="#f0be75" />
</marker>
</defs>
</svg>
</div>
{/* 右列:事件 */}
<div className="w-full md:w-1/2 flex flex-col gap-3 md:gap-4">
{rightItems.map((item) => {
const isMatched = isRightItemMatched(item.id);
const matchedLeftId = getMatchedLeftId(item.id);
return (
<div
key={item.id}
className={cn(
"relative px-4 md:px-6 py-3 md:py-4 rounded-xl border-2 transition-all duration-200",
"text-[20px] md:text-[28px] leading-relaxed",
"touch-manipulation", // 优化移动端触摸
disabled || isMatched
? "opacity-60 cursor-not-allowed"
: selectedLeftId
? "cursor-pointer"
: "cursor-default",
isMatched
? "bg-[#3a3020] text-[#d4c5a9] border-[#8b7355]"
: selectedLeftId
? "bg-[#d7b888] text-[#5c3e2e] border-[#d4c5a9] hover:bg-gradient-to-r hover:from-[#ddf3e1] hover:to-[#fbdd97] hover:border-[#f0be75]"
: "bg-[#d7b888]/60 text-[#5c3e2e]/60 border-[#d4c5a9]/60"
)}
onClick={() => handleRightItemClick(item.id)}
onTouchStart={(e) => {
if (selectedLeftId) {
e.currentTarget.classList.add("scale-105");
}
}}
onTouchEnd={(e) => {
e.currentTarget.classList.remove("scale-105");
}}
>
{item.label}
{isMatched && (
<div className="absolute -left-2 md:-left-4 top-1/2 -translate-y-1/2 w-4 h-4 md:w-6 md:h-6 bg-green-500 rounded-full flex items-center justify-center">
<span className="text-white text-xs md:text-sm">✓</span>
</div>
)}
</div>
);
})}
</div>
</div>
{/* 移动端连线提示 */}
<div className="md:hidden mt-4 text-center text-[18px] text-white/80">
请先点击左侧日期,再点击右侧对应的事件进行连线
</div>
</div>
</div>
);
};
export default MatchQuizPanel;
+4
View File
@@ -2,10 +2,14 @@
export { default as SingleChoiceQuizPanel } from "./SingleChoiceQuizPanel";
export { default as MultipleChoiceQuizPanel } from "./MultipleChoiceQuizPanel";
export { default as DragQuizPanel } from "./DragQuizPanel";
export { default as MatchQuizPanel } from "./MatchQuizPanel";
export type {
SingleChoiceQuizPanelProps,
MultipleChoiceQuizPanelProps,
DragQuizPanelProps,
MatchQuizPanelProps,
MatchQuizItem,
MatchQuizMatch,
} from "./types";
// 保留旧组件以保持向后兼容
+40
View File
@@ -85,6 +85,46 @@ export interface MultipleChoiceQuizPanelProps {
submitText?: string;
}
/** 连线题项 */
export interface MatchQuizItem {
/** 唯一标识 */
id: string;
/** 显示文本 */
label: string;
}
/** 连线题匹配 */
export interface MatchQuizMatch {
/** 左列项ID */
leftId: string;
/** 右列项ID */
rightId: string;
}
/** 连线题面板配置 */
export interface MatchQuizPanelProps {
/** 是否可见 */
visible: boolean;
/** z-index 层级 */
zIndex?: number;
/** 题目 */
title: string;
/** 左列项列表(日期) */
leftItems: MatchQuizItem[];
/** 右列项列表(事件) */
rightItems: MatchQuizItem[];
/** 正确答案匹配列表 */
correctMatches: MatchQuizMatch[];
/** 自定义错误提示信息(答案错误时显示) */
errorMessage?: string;
/** 提交回调,返回用户匹配和是否正确 */
onSubmit?: (matches: MatchQuizMatch[], isCorrect: boolean) => void;
/** 错误回调(可选) */
onError?: (error: string) => void;
/** 是否禁用 */
disabled?: boolean;
}
/** @deprecated 使用 SingleChoiceQuizPanelProps 替代 */
export interface QuizPanelProps {
visible: boolean;
+264 -15
View File
@@ -91,6 +91,8 @@ const Video = forwardRef<VideoRef, VideoProps>(
const [isPlaying, setIsPlaying] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isVideoReady, setIsVideoReady] = useState(false);
// 跟踪是否应该停在锚点处(用于 seek 时强制暂停)
const shouldStopAtAnchorRef = useRef<{ time: number; anchorId: string } | null>(null);
useImperativeHandle(ref, () => ({
video: videoRef.current,
@@ -250,11 +252,151 @@ const Video = forwardRef<VideoRef, VideoProps>(
}
};
// Seek 时重置隐藏状态(非 once 的锚点可以重新触发)
// 检查并触发锚点的辅助函数
const checkAndTriggerAnchors = useCallback((targetTime: number) => {
if (!videoRef.current || isHandlingAnchor.current) return;
for (const anchor of anchors) {
// 检查是否启用
if (anchor.enabled === false) continue;
// 检查是否已移除
if (removedAnchorIds.has(anchor.id)) continue;
// 检查 once 逻辑
if (anchor.once && triggeredOnceIds.has(anchor.id)) continue;
// 检查是否已隐藏(隐藏的锚点不再触发)
if (hiddenAnchorIds.has(anchor.id)) continue;
// keep 模式下,如果当前锚点已激活,跳过(避免重复触发暂停)
if (activeAnchorId === anchor.id) continue;
// 检查是否跳过了锚点时间(允许0.5秒的容差)
const timeDiff = targetTime - anchor.time;
if (timeDiff >= 0 && timeDiff < 0.5) {
console.log(`Anchor triggered: ${anchor.id} at ${anchor.time}s (seeked to ${targetTime}s)`);
// 立即暂停视频(多次确认确保暂停生效)
if (videoRef.current) {
videoRef.current.pause();
// 同步检查并再次暂停(Safari 兼容)
if (!videoRef.current.paused) {
videoRef.current.pause();
}
// 双重保险:延迟再次确认暂停
requestAnimationFrame(() => {
if (videoRef.current && !videoRef.current.paused) {
videoRef.current.pause();
}
});
// 三重保险:再延迟一次确认
setTimeout(() => {
if (videoRef.current && !videoRef.current.paused) {
videoRef.current.pause();
}
}, 50);
}
isHandlingAnchor.current = true;
// 标记为已触发(用于 once 逻辑)
if (anchor.once) {
setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id));
}
// 设置当前激活的锚点
setActiveAnchorId(anchor.id);
// 触发回调(如埋点)
anchor.onTrigger?.();
break;
}
}
}, [anchors, removedAnchorIds, triggeredOnceIds, hiddenAnchorIds, activeAnchorId]);
// 查找目标时间之前最近的未触发锚点
const findNearestAnchorBeforeTime = useCallback((targetTime: number): VideoAnchor | null => {
let nearestAnchor: VideoAnchor | null = null;
let nearestTime = -1;
for (const anchor of anchors) {
// 检查是否启用
if (anchor.enabled === false) continue;
// 检查是否已移除
if (removedAnchorIds.has(anchor.id)) continue;
// 检查 once 逻辑
if (anchor.once && triggeredOnceIds.has(anchor.id)) continue;
// 检查是否已隐藏(隐藏的锚点不再触发)
if (hiddenAnchorIds.has(anchor.id)) continue;
// 锚点时间应该在目标时间之前或等于目标时间
if (anchor.time <= targetTime && anchor.time > nearestTime) {
nearestAnchor = anchor;
nearestTime = anchor.time;
}
}
return nearestAnchor;
}, [anchors, removedAnchorIds, triggeredOnceIds, hiddenAnchorIds]);
// 开始跳转时,如果应该停在锚点处,立即暂停
const handleSeeking = (): void => {
if (!videoRef.current) return;
// 如果设置了应该停在锚点处,立即暂停
if (shouldStopAtAnchorRef.current) {
videoRef.current.pause();
// 同步检查并再次暂停(Safari 兼容)
if (!videoRef.current.paused) {
videoRef.current.pause();
}
}
};
// Seek 时重置隐藏状态(非 once 的锚点可以重新触发),并检查是否跳过了锚点
const handleSeeked = (): void => {
if (!videoRef.current) return;
const currentTime = videoRef.current.currentTime;
// 如果设置了应该停在锚点处,强制暂停并触发锚点
if (shouldStopAtAnchorRef.current) {
const { time: anchorTime, anchorId } = shouldStopAtAnchorRef.current;
// 检查当前时间是否在锚点附近(允许0.1秒容差)
if (Math.abs(currentTime - anchorTime) < 0.1) {
// 强制暂停(多次确认)
videoRef.current.pause();
if (!videoRef.current.paused) {
videoRef.current.pause();
}
requestAnimationFrame(() => {
if (videoRef.current && !videoRef.current.paused) {
videoRef.current.pause();
}
});
// 触发锚点逻辑
if (!isHandlingAnchor.current) {
const anchor = anchors.find((a) => a.id === anchorId);
if (anchor) {
isHandlingAnchor.current = true;
// 标记为已触发(用于 once 逻辑)
if (anchor.once) {
setTriggeredOnceIds((prev) => new Set(prev).add(anchor.id));
}
// 设置当前激活的锚点
setActiveAnchorId(anchor.id);
// 触发回调(如埋点)
anchor.onTrigger?.();
}
}
// 清除标记
shouldStopAtAnchorRef.current = null;
return;
}
// 如果时间不匹配,清除标记
shouldStopAtAnchorRef.current = null;
}
// 清除当前时间之后的隐藏状态(允许重新触发)
setHiddenAnchorIds((prev) => {
const next = new Set(prev);
@@ -267,11 +409,13 @@ const Video = forwardRef<VideoRef, VideoProps>(
return next;
});
// 重置锚点处理锁
if (activeAnchorId) {
setActiveAnchorId(null);
// 重置锚点处理锁(如果当前没有激活的锚点)
if (!activeAnchorId) {
isHandlingAnchor.current = false;
}
isHandlingAnchor.current = false;
// 检查是否跳过了锚点时间,如果是则触发锚点
checkAndTriggerAnchors(currentTime);
};
// 获取当前激活的锚点
@@ -300,10 +444,53 @@ const Video = forwardRef<VideoRef, VideoProps>(
};
const handleSkipClick = (): void => {
// 优先暂停当前视频
if (videoRef.current) {
videoRef.current.pause();
if (!videoRef.current) return;
const currentTime = videoRef.current.currentTime;
const videoDuration = videoRef.current.duration;
// 检查是否有未触发的锚点
let hasUntriggeredAnchor = false;
for (const anchor of anchors) {
// 检查是否启用
if (anchor.enabled === false) continue;
// 检查是否已移除
if (removedAnchorIds.has(anchor.id)) continue;
// 检查 once 逻辑
if (anchor.once && triggeredOnceIds.has(anchor.id)) continue;
// 检查是否已隐藏
if (hiddenAnchorIds.has(anchor.id)) continue;
// 检查锚点时间是否在当前时间和视频结束之间
if (anchor.time >= currentTime && anchor.time <= videoDuration) {
hasUntriggeredAnchor = true;
break;
}
}
// 如果有未触发的锚点,跳转到第一个未触发的锚点时间并触发
if (hasUntriggeredAnchor) {
// 找到第一个未触发的锚点
const nextAnchor = anchors.find((anchor) => {
if (anchor.enabled === false) return false;
if (removedAnchorIds.has(anchor.id)) return false;
if (anchor.once && triggeredOnceIds.has(anchor.id)) return false;
if (hiddenAnchorIds.has(anchor.id)) return false;
return anchor.time >= currentTime && anchor.time <= videoDuration;
});
if (nextAnchor) {
// 跳转到锚点时间
videoRef.current.currentTime = nextAnchor.time;
// 触发锚点(通过 checkAndTriggerAnchors)
setTimeout(() => {
checkAndTriggerAnchors(nextAnchor.time);
}, 100);
return;
}
}
// 如果没有未触发的锚点,执行原来的跳过逻辑
videoRef.current.pause();
onSkip?.();
// 触发与自然结束相同的逻辑
if (props.onEnded) {
@@ -331,8 +518,28 @@ const Video = forwardRef<VideoRef, VideoProps>(
if (!videoRef.current || !progressRef.current) return;
const rect = progressRef.current.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
const newTime = percent * duration;
videoRef.current.currentTime = newTime;
const targetTime = percent * duration;
// 检查目标时间之前是否有未触发的锚点
const nearestAnchor = findNearestAnchorBeforeTime(targetTime);
// 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
// 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
const shouldStopAtAnchor = nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1;
const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime;
// 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
if (shouldStopAtAnchor && nearestAnchor) {
shouldStopAtAnchorRef.current = {
time: finalTime,
anchorId: nearestAnchor.id,
};
// 立即暂停视频
videoRef.current.pause();
} else {
shouldStopAtAnchorRef.current = null;
}
videoRef.current.currentTime = finalTime;
resetControlsTimer();
};
@@ -345,8 +552,28 @@ const Video = forwardRef<VideoRef, VideoProps>(
0,
Math.min(1, (e.clientX - rect.left) / rect.width)
);
const newTime = percent * duration;
videoRef.current.currentTime = newTime;
const targetTime = percent * duration;
// 检查目标时间之前是否有未触发的锚点
const nearestAnchor = findNearestAnchorBeforeTime(targetTime);
// 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
// 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
const shouldStopAtAnchor = nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1;
const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime;
// 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
if (shouldStopAtAnchor && nearestAnchor) {
shouldStopAtAnchorRef.current = {
time: finalTime,
anchorId: nearestAnchor.id,
};
// 立即暂停视频
videoRef.current.pause();
} else {
shouldStopAtAnchorRef.current = null;
}
videoRef.current.currentTime = finalTime;
};
// 全局鼠标移动和释放事件处理拖拽
@@ -360,12 +587,33 @@ const Video = forwardRef<VideoRef, VideoProps>(
0,
Math.min(1, (e.clientX - rect.left) / rect.width)
);
const newTime = percent * duration;
videoRef.current.currentTime = newTime;
const targetTime = percent * duration;
// 检查目标时间之前是否有未触发的锚点
const nearestAnchor = findNearestAnchorBeforeTime(targetTime);
// 如果找到锚点,且目标时间跳过了锚点(目标时间 > 锚点时间),则停在锚点处
// 但只有当目标时间在锚点之后超过0.1秒时才停在锚点,避免点击锚点本身时也被拦截
const shouldStopAtAnchor = nearestAnchor && targetTime > nearestAnchor.time && targetTime - nearestAnchor.time > 0.1;
const finalTime = shouldStopAtAnchor ? nearestAnchor!.time : targetTime;
// 如果会停在锚点处,设置标记(用于 onSeeking 和 onSeeked 事件)
if (shouldStopAtAnchor && nearestAnchor) {
shouldStopAtAnchorRef.current = {
time: finalTime,
anchorId: nearestAnchor.id,
};
// 立即暂停视频
videoRef.current.pause();
} else {
shouldStopAtAnchorRef.current = null;
}
videoRef.current.currentTime = finalTime;
};
const handleMouseUp = () => {
setIsDragging(false);
// 拖拽结束后,handleSeeked 会自动处理锚点触发
};
document.addEventListener("mousemove", handleMouseMove);
@@ -375,7 +623,7 @@ const Video = forwardRef<VideoRef, VideoProps>(
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, duration]);
}, [isDragging, duration, checkAndTriggerAnchors, findNearestAnchorBeforeTime]);
// 音量控制
const toggleMute = () => {
@@ -434,6 +682,7 @@ const Video = forwardRef<VideoRef, VideoProps>(
className="w-full h-full object-cover"
controls={false}
onTimeUpdate={handleTimeUpdate}
onSeeking={handleSeeking}
onSeeked={handleSeeked}
onEnded={handleEnded}
onPlay={handlePlay}