288 lines
12 KiB
TypeScript
288 lines
12 KiB
TypeScript
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<ITrainingTip> = (tip: ITrainingTip) => {
|
|
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
|
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
|
|
const [highlightedPhrases, setHighlightedPhrases] = useState<string[]>([]);
|
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
|
const timelineRef = useRef<TimelineEvent[]>([]);
|
|
const animationRef = useRef<number | null>(null);
|
|
const segmentsRef = useRef<SegmentRef[]>([]);
|
|
|
|
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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className="container mx-auto">
|
|
<h1 className='text-xl font-bold text-red-600'>The exercise for this tip is not available yet!</h1>
|
|
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4 mt-10">
|
|
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
|
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
return (
|
|
<div className="container mx-auto">
|
|
<div className="tip-container bg-blue-100 p-4 rounded-lg shadow-md mb-4">
|
|
<h3 className="text-xl font-semibold text-blue-800 mb-2">{tip.tipCategory}</h3>
|
|
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.tipHtml }} />
|
|
</div>
|
|
<div className='flex flex-col space-y-4'>
|
|
<div className='flex flex-row items-center space-x-4 py-4'>
|
|
<button
|
|
onClick={toggleAutoPlay}
|
|
className="p-2 bg-blue-500 text-white rounded-full transition-colors duration-200 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
|
aria-label={isAutoPlaying ? 'Pause' : 'Play'}
|
|
>
|
|
{isAutoPlaying ? (
|
|
<FaRegCircleStop className="w-6 h-6" />
|
|
) : (
|
|
<FaRegCirclePlay className="w-6 h-6" />
|
|
)}
|
|
</button>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max={timelineRef.current.length > 0 ? timelineRef.current[timelineRef.current.length - 1].end : 0}
|
|
value={currentTime}
|
|
onChange={handleSliderChange}
|
|
onMouseDown={handleSliderMouseDown}
|
|
onMouseUp={handleSliderMouseUp}
|
|
onTouchStart={handleSliderMouseDown}
|
|
onTouchEnd={handleSliderMouseUp}
|
|
className='flex-grow'
|
|
/>
|
|
</div>
|
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4'>
|
|
<div className='flex-1 bg-white p-6 rounded-lg shadow'>
|
|
{/*<h2 className="text-xl font-bold mb-4">Question</h2>*/}
|
|
<div className="mb-4" dangerouslySetInnerHTML={{ __html: tip.exercise.question }} />
|
|
<HighlightContent html={tip.exercise.highlightable} highlightPhrases={highlightedPhrases} />
|
|
</div>
|
|
<div className='flex-1'>
|
|
<div className='bg-gray-50 rounded-lg shadow'>
|
|
<div className='p-6 space-y-4'>
|
|
<animated.div
|
|
dangerouslySetInnerHTML={{ __html: walkthroughHtml }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ExerciseWalkthrough;
|