Files
encoach_frontend/src/components/TrainingContent/ExerciseWalkthrough.tsx
2024-08-18 08:07:16 +01:00

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;