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; }); }, [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;