training.tsx still a bit messy, all that is left is to retrieve data from firestore /training and /walkthrough and render it
This commit is contained in:
287
src/components/TrainingContent/ExerciseWalkthrough.tsx
Normal file
287
src/components/TrainingContent/ExerciseWalkthrough.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { animated } from '@react-spring/web';
|
||||
import { FaRegCirclePlay, FaRegCircleStop } from "react-icons/fa6";
|
||||
import HighlightedContent from './AnimatedHighlight';
|
||||
import { SegmentRef, TimelineEvent, WalkthroughConfigs } from './TrainingInterfaces';
|
||||
|
||||
interface ExerciseWalkthroughProps {
|
||||
highlightable: string;
|
||||
walkthrough: WalkthroughConfigs[];
|
||||
question: string;
|
||||
tip: {
|
||||
category: string;
|
||||
body: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ExerciseWalkthrough: React.FC<ExerciseWalkthroughProps> = ({
|
||||
highlightable,
|
||||
walkthrough,
|
||||
question,
|
||||
tip
|
||||
}) => {
|
||||
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 walkthrough.reduce((sum, segment) =>
|
||||
sum + segment.wordDelay * segment.html.split(/\s+/).length + segment.holdDelay, 0);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const timeline: TimelineEvent[] = [];
|
||||
let currentTimePosition = 0;
|
||||
segmentsRef.current = [];
|
||||
|
||||
walkthrough.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;
|
||||
}, [walkthrough]);
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
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.category}</h3>
|
||||
<div className="text-gray-700" dangerouslySetInnerHTML={{ __html: tip.body }} />
|
||||
</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: question }} />
|
||||
<HighlightedContent html={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;
|
||||
Reference in New Issue
Block a user