400 lines
13 KiB
TypeScript
400 lines
13 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, HighlightConfig, InsertHtmlConfig } from './TrainingInterfaces';
|
|
import Tip from './Tip';
|
|
|
|
interface HtmlState {
|
|
question: string;
|
|
additional: string;
|
|
walkthrough: string;
|
|
}
|
|
|
|
const ExerciseWalkthrough: React.FC<ITrainingTip> = (tip: ITrainingTip) => {
|
|
const [isAutoPlaying, setIsAutoPlaying] = useState<boolean>(false);
|
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
|
const [currentHighlightConfigs, setCurrentHighlightConfigs] = useState<HighlightConfig[]>([]);
|
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
|
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
|
|
const timelineRef = useRef<TimelineEvent[]>([]);
|
|
const animationRef = useRef<number | null>(null);
|
|
const segmentsRef = useRef<SegmentRef[]>([]);
|
|
|
|
const [questionHtml, setQuestionHtml] = useState(tip.exercise?.question || '');
|
|
const [additionalHtml, setAdditionalHtml] = useState(tip.exercise?.additional || '');
|
|
const [walkthroughHtml, setWalkthroughHtml] = useState<string>('');
|
|
const [htmlStates, setHtmlStates] = useState<HtmlState[]>([]);
|
|
const lastProcessedInsertTime = useRef<number>(-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<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 py-6">
|
|
<Tip category={tip.tipCategory} html={tip.tipHtml} />
|
|
{!tip.standalone && (
|
|
<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 w-full'>
|
|
<div className='flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 w-full'>
|
|
<div className='flex-1 bg-white p-6 rounded-lg shadow space-y-6'>
|
|
<div className="container mx-auto px-4">
|
|
<div id="question-container" className="border p-6 rounded-lg shadow-md">
|
|
<HighlightContent
|
|
html={questionHtml}
|
|
highlightConfigs={currentHighlightConfigs}
|
|
contentType="question"
|
|
/>
|
|
</div>
|
|
</div>
|
|
{tip.exercise?.additional && (<div className="container mx-auto px-4">
|
|
<div id="additional-container" className="border p-6 rounded-lg shadow-md">
|
|
<HighlightContent
|
|
html={additionalHtml}
|
|
highlightConfigs={currentHighlightConfigs}
|
|
contentType="additional"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className='flex-1'>
|
|
<div className='bg-gray-50 rounded-lg shadow'>
|
|
<div id="segment-container" className='p-6 space-y-4'>
|
|
<animated.div>
|
|
<HighlightContent
|
|
html={walkthroughHtml}
|
|
highlightConfigs={currentHighlightConfigs.filter(config =>
|
|
config.targets.includes('segment') || config.targets.includes('all')
|
|
)}
|
|
contentType="segment"
|
|
currentSegmentIndex={currentSegmentIndex}
|
|
/>
|
|
</animated.div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ExerciseWalkthrough; |