import React, {useState, useEffect, useRef, useCallback} from "react"; import {animated} from "@react-spring/web"; import {FaRegCirclePlay, FaRegCircleStop} from "react-icons/fa6"; import HighlightContent from "../HighlightContent"; import {ITrainingTip, SegmentRef, TimelineEvent} from "./TrainingInterfaces"; const ExerciseWalkthrough: React.FC = (tip: ITrainingTip) => { const [isAutoPlaying, setIsAutoPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [walkthroughHtml, setWalkthroughHtml] = useState(""); const [highlightedPhrases, setHighlightedPhrases] = useState([]); const [isPlaying, setIsPlaying] = useState(false); const timelineRef = useRef([]); const animationRef = useRef(null); const segmentsRef = useRef([]); const toggleAutoPlay = useCallback(() => { setIsAutoPlaying((prev) => { if (!prev && currentTime === getMaxTime()) { setCurrentTime(0); } return !prev; }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentTime]); const handleAnimationComplete = useCallback(() => { setIsAutoPlaying(false); }, []); const handleResetAnimation = useCallback((newTime: number) => { setCurrentTime(newTime); }, []); const getMaxTime = (): number => { return ( tip.exercise?.segments.reduce((sum, segment) => sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0) ?? 0 ); }; useEffect(() => { const timeline: TimelineEvent[] = []; let currentTimePosition = 0; segmentsRef.current = []; tip.exercise?.segments.forEach((segment, index) => { const parser = new DOMParser(); const doc = parser.parseFromString(segment.html, "text/html"); const words: string[] = []; const walkTree = (node: Node) => { if (node.nodeType === Node.TEXT_NODE) { words.push(...(node.textContent?.split(/\s+/).filter((word) => word.length > 0) || [])); } else if (node.nodeType === Node.ELEMENT_NODE) { Array.from(node.childNodes).forEach(walkTree); } }; walkTree(doc.body); const textDuration = words.length * segment.wordDelay; segmentsRef.current.push({ ...segment, words: words, startTime: currentTimePosition, endTime: currentTimePosition + textDuration, }); timeline.push({ type: "text", start: currentTimePosition, end: currentTimePosition + textDuration, segmentIndex: index, }); currentTimePosition += textDuration; timeline.push({ type: "highlight", start: currentTimePosition, end: currentTimePosition + segment.holdDelay, content: segment.highlight, segmentIndex: index, }); currentTimePosition += segment.holdDelay; }); timelineRef.current = timeline; }, [tip.exercise?.segments]); const updateText = useCallback(() => { const currentEvent = timelineRef.current.find((event) => currentTime >= event.start && currentTime < event.end); if (currentEvent) { if (currentEvent.type === "text") { const segment = segmentsRef.current[currentEvent.segmentIndex]; const elapsedTime = currentTime - currentEvent.start; const wordsToShow = Math.min(Math.floor(elapsedTime / segment.wordDelay), segment.words.length); const previousSegmentsHtml = segmentsRef.current .slice(0, currentEvent.segmentIndex) .map((seg) => seg.html) .join(""); const parser = new DOMParser(); const doc = parser.parseFromString(segment.html, "text/html"); let wordCount = 0; const walkTree = (node: Node, action: (node: Node) => void): boolean => { if (node.nodeType === Node.TEXT_NODE && node.textContent) { const words = node.textContent.split(/(\s+)/).filter((word) => word.length > 0); if (wordCount + words.filter((w) => !/\s+/.test(w)).length <= wordsToShow) { action(node.cloneNode(true)); wordCount += words.filter((w) => !/\s+/.test(w)).length; } else { const remainingWords = wordsToShow - wordCount; const newTextContent = words.reduce( (acc, word) => { if (!/\s+/.test(word) && acc.nonSpaceWords < remainingWords) { acc.text += word; acc.nonSpaceWords++; } else if (/\s+/.test(word) || acc.nonSpaceWords < remainingWords) { acc.text += word; } return acc; }, {text: "", nonSpaceWords: 0}, ).text; const newNode = node.cloneNode(false); newNode.textContent = newTextContent; action(newNode); wordCount = wordsToShow; } } else if (node.nodeType === Node.ELEMENT_NODE) { const clone = node.cloneNode(false); action(clone); Array.from(node.childNodes).some((child) => { return walkTree(child, (childNode) => (clone as Node).appendChild(childNode)); }); } return wordCount >= wordsToShow; }; const fragment = document.createDocumentFragment(); walkTree(doc.body, (node) => fragment.appendChild(node)); const serializer = new XMLSerializer(); const currentSegmentHtml = Array.from(fragment.childNodes) .map((node) => serializer.serializeToString(node)) .join(""); const newHtml = previousSegmentsHtml + currentSegmentHtml; setWalkthroughHtml(newHtml); setHighlightedPhrases([]); } else if (currentEvent.type === "highlight") { const newHtml = segmentsRef.current .slice(0, currentEvent.segmentIndex + 1) .map((seg) => seg.html) .join(""); setWalkthroughHtml(newHtml); setHighlightedPhrases(currentEvent.content || []); } } }, [currentTime]); useEffect(() => { updateText(); }, [currentTime, updateText]); useEffect(() => { if (isAutoPlaying) { const lastEvent = timelineRef.current[timelineRef.current.length - 1]; if (lastEvent && currentTime >= lastEvent.end) { setCurrentTime(0); } setIsPlaying(true); } else { setIsPlaying(false); } }, [isAutoPlaying, currentTime]); useEffect(() => { const animate = () => { if (isPlaying) { setCurrentTime((prevTime) => { const newTime = prevTime + 50; const lastEvent = timelineRef.current[timelineRef.current.length - 1]; if (lastEvent && newTime >= lastEvent.end) { setIsPlaying(false); handleAnimationComplete(); return lastEvent.end; } return newTime; }); } animationRef.current = requestAnimationFrame(animate); }; animationRef.current = requestAnimationFrame(animate); return () => { if (animationRef.current) { cancelAnimationFrame(animationRef.current); } }; }, [isPlaying, handleAnimationComplete]); const handleSliderChange = (e: React.ChangeEvent) => { const newTime = parseInt(e.target.value, 10); setCurrentTime(newTime); handleResetAnimation(newTime); }; const handleSliderMouseDown = () => { setIsPlaying(false); }; const handleSliderMouseUp = () => { if (isAutoPlaying) { setIsPlaying(true); } }; if (tip.standalone || !tip.exercise) { return (

The exercise for this tip is not available yet!

{tip.tipCategory}

); } return (

{tip.tipCategory}

0 ? timelineRef.current[timelineRef.current.length - 1].end : 0} value={currentTime} onChange={handleSliderChange} onMouseDown={handleSliderMouseDown} onMouseUp={handleSliderMouseUp} onTouchStart={handleSliderMouseDown} onTouchEnd={handleSliderMouseUp} className="flex-grow" />
{/*

Question

*/}
); }; export default ExerciseWalkthrough;