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, HighlightConfig, InsertHtmlConfig } from './TrainingInterfaces'; import Tip from './Tip'; interface HtmlState { question: string; additional: string; walkthrough: string; } const ExerciseWalkthrough: React.FC = (tip: ITrainingTip) => { const [isAutoPlaying, setIsAutoPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState([]); const [isPlaying, setIsPlaying] = useState(false); const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0); const timelineRef = useRef([]); const animationRef = useRef(null); const segmentsRef = useRef([]); const [questionHtml, setQuestionHtml] = useState(tip.exercise?.question || ''); const [additionalHtml, setAdditionalHtml] = useState(tip.exercise?.additional || ''); const [walkthroughHtml, setWalkthroughHtml] = useState(''); const [htmlStates, setHtmlStates] = useState([]); const lastProcessedInsertTime = useRef(-1); 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 = []; const newHtmlStates: HtmlState[] = []; 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 }); if (segment.insertHTML && segment.insertHTML.length > 0) { newHtmlStates.push({ question: questionHtml, additional: additionalHtml, walkthrough: walkthroughHtml }); timeline.push({ type: 'insert', start: currentTimePosition, end: currentTimePosition + segment.holdDelay, segmentIndex: index, content: segment.insertHTML }); } currentTimePosition += segment.holdDelay; }); for (let i = 0; i < timeline.length; i++) { if (timeline[i].type === 'insert') { const nextInsertIndex = timeline.findIndex((event, index) => index > i && event.type === 'insert'); if (nextInsertIndex !== -1) { timeline[i].end = timeline[nextInsertIndex].start; } else { timeline[i].end = currentTimePosition; } } } timelineRef.current = timeline; setHtmlStates(newHtmlStates); }, [tip.exercise?.segments, questionHtml, additionalHtml, walkthroughHtml]); const updateText = useCallback(() => { const currentEvents = timelineRef.current.filter( event => currentTime >= event.start && currentTime <= event.end ); if (currentTime < lastProcessedInsertTime.current) { const lastInsertEvent = timelineRef.current .filter(event => event.type === 'insert' && event.start <= currentTime) .pop(); if (lastInsertEvent) { const stateIndex = timelineRef.current.indexOf(lastInsertEvent); if (stateIndex >= 0 && stateIndex < htmlStates.length) { const previousState = htmlStates[stateIndex]; setQuestionHtml(previousState.question); setAdditionalHtml(previousState.additional); setWalkthroughHtml(previousState.walkthrough); } } else { // If no previous insert event, revert to initial state setQuestionHtml(tip.exercise?.question || ''); setAdditionalHtml(tip.exercise?.additional || ''); setWalkthroughHtml(''); } } currentEvents.forEach(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); setCurrentSegmentIndex(currentEvent.segmentIndex); setCurrentHighlightConfigs([]); } else if (currentEvent.type === 'highlight') { const newHtml = segmentsRef.current .slice(0, currentEvent.segmentIndex + 1) .map(seg => seg.html) .join(''); setWalkthroughHtml(newHtml); setCurrentSegmentIndex(currentEvent.segmentIndex); setCurrentHighlightConfigs(currentEvent.content as HighlightConfig[] || []); } else if (currentEvent.type === 'insert') { const insertConfigs = currentEvent.content as InsertHtmlConfig[]; insertConfigs.forEach(config => { switch (config.target) { case 'question': setQuestionHtml(prevHtml => insertHtmlContent(prevHtml, config)); break; case 'additional': setAdditionalHtml(prevHtml => insertHtmlContent(prevHtml, config)); break; case 'segment': setWalkthroughHtml(prevHtml => insertHtmlContent(prevHtml, config)); break; } }); lastProcessedInsertTime.current = currentTime; } }); }, [currentTime, htmlStates, tip.exercise?.question, tip.exercise?.additional]); useEffect(() => { updateText(); }, [currentTime, updateText]); const insertHtmlContent = (prevHtml: string, config: InsertHtmlConfig): string => { const tempDiv = document.createElement('div'); tempDiv.innerHTML = prevHtml; const targetElement = tempDiv.querySelector(`#${config.targetId}`); if (targetElement) { switch (config.position) { case 'append': targetElement.insertAdjacentHTML('beforeend', config.html); break; case 'prepend': targetElement.insertAdjacentHTML('afterbegin', config.html); break; case 'replace': targetElement.innerHTML = config.html; break; } } return tempDiv.innerHTML; }; 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); } }; return (
{!tip.standalone && (
0 ? timelineRef.current[timelineRef.current.length - 1].end : 0} value={currentTime} onChange={handleSliderChange} onMouseDown={handleSliderMouseDown} onMouseUp={handleSliderMouseUp} onTouchStart={handleSliderMouseDown} onTouchEnd={handleSliderMouseUp} className='flex-grow' />
{tip.exercise?.additional && (
)}
config.targets.includes('segment') || config.targets.includes('all') )} contentType="segment" currentSegmentIndex={currentSegmentIndex} />
)}
); }; export default ExerciseWalkthrough;