import BlankQuestionsModal from "@/components/BlankQuestionsModal"; import {renderExercise} from "@/components/Exercises"; import HighlightContent from "@/components/HighlightContent"; import Button from "@/components/Low/Button"; import ModuleTitle from "@/components/Medium/ModuleTitle"; import {renderSolution} from "@/components/Solutions"; import {infoButtonStyle} from "@/constants/buttonStyles"; import {Module} from "@/interfaces"; import { Exercise, FillBlanksExercise, FillBlanksMCOption, LevelExam, LevelPart, MultipleChoiceExercise, ShuffleMap, UserSolution, WritingExam, } from "@/interfaces/exam"; import useExamStore from "@/stores/examStore"; import {defaultUserSolutions} from "@/utils/exams"; import {countExercises} from "@/utils/moduleUtils"; import {mdiArrowRight} from "@mdi/js"; import Icon from "@mdi/react"; import clsx from "clsx"; import {Dispatch, Fragment, SetStateAction, use, useEffect, useMemo, useRef, useState} from "react"; import {BsChevronDown, BsChevronUp} from "react-icons/bs"; import {toast} from "react-toastify"; import {v4} from "uuid"; interface Props { exam: LevelExam; showSolutions?: boolean; onFinish: (userSolutions: UserSolution[]) => void; editing?: boolean; } function TextComponent({ part, contextWord, setContextWordLine, }: { part: LevelPart; contextWord: string | undefined; setContextWordLine: React.Dispatch>; }) { const textRef = useRef(null); const [lineNumbers, setLineNumbers] = useState([]); const [lineHeight, setLineHeight] = useState(0); part.showContextLines = true; const calculateLineNumbers = () => { if (textRef.current) { const computedStyle = window.getComputedStyle(textRef.current); const lineHeightValue = parseFloat(computedStyle.lineHeight); const containerWidth = textRef.current.clientWidth; setLineHeight(lineHeightValue); const offscreenElement = document.createElement("div"); offscreenElement.style.position = "absolute"; offscreenElement.style.top = "-9999px"; offscreenElement.style.left = "-9999px"; offscreenElement.style.whiteSpace = "pre-wrap"; offscreenElement.style.width = `${containerWidth}px`; offscreenElement.style.font = computedStyle.font; offscreenElement.style.lineHeight = computedStyle.lineHeight; offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign; const textContent = textRef.current.textContent || ""; textContent.split(/(\s+)/).forEach((word: string) => { const span = document.createElement("span"); span.textContent = word; span.style.display = "inline-block"; span.style.height = `calc(1em + 16px)`; offscreenElement.appendChild(span); }); document.body.appendChild(offscreenElement); const lines: string[][] = [[]]; let currentLine = 1; let currentLineTop: number | undefined; let contextWordLine: number | null = null; const firstChild = offscreenElement.firstChild as HTMLElement; if (firstChild) { currentLineTop = firstChild.getBoundingClientRect().top; } const spans = offscreenElement.querySelectorAll("span"); spans.forEach((span) => { const rect = span.getBoundingClientRect(); const top = rect.top; if (currentLineTop !== undefined && top > currentLineTop) { currentLine++; currentLineTop = top; lines.push([]); } lines[lines.length - 1].push(span.textContent?.trim() || ""); if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) { contextWordLine = currentLine; } }); setLineNumbers(lines.map((_, index) => index + 1)); if (contextWordLine) { setContextWordLine(contextWordLine); } document.body.removeChild(offscreenElement); } }; useEffect(() => { calculateLineNumbers(); const resizeObserver = new ResizeObserver(() => { calculateLineNumbers(); }); if (textRef.current) { resizeObserver.observe(textRef.current); } return () => { if (textRef.current) { // eslint-disable-next-line react-hooks/exhaustive-deps resizeObserver.unobserve(textRef.current); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [part.context, part.showContextLines, contextWord]); if (typeof part.showContextLines === "undefined") { return (
{!!part.context && part.context .split(/\n|(\\n)/g) .filter((x) => x && x.length > 0 && x !== "\\n") .map((line, index) => (

{line}

))}
); } return (
{part.context!.split("\n\n").map((line, index) => { return (

{index + 1} {line}

); })}
); } const typeCheckWordsMC = (words: any[]): words is FillBlanksMCOption[] => { return Array.isArray(words) && words.every((word) => word && typeof word === "object" && "id" in word && "options" in word); }; export default function Level({exam, showSolutions = false, onFinish, editing = false}: Props) { const [multipleChoicesDone, setMultipleChoicesDone] = useState<{id: string; amount: number}[]>([]); const [showBlankModal, setShowBlankModal] = useState(false); const {userSolutions, setUserSolutions} = useExamStore((state) => state); const {hasExamEnded, setHasExamEnded} = useExamStore((state) => state); const {partIndex, setPartIndex} = useExamStore((state) => state); const {exerciseIndex, setExerciseIndex} = useExamStore((state) => state); const [storeQuestionIndex, setStoreQuestionIndex] = useExamStore((state) => [state.questionIndex, state.setQuestionIndex]); //const [shuffleMaps, setShuffleMaps] = useExamStore((state) => [state.shuffleMaps, state.setShuffleMaps]) const [currentExercise, setCurrentExercise] = useState(); const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0)); const [contextWord, setContextWord] = useState(undefined); const [contextWordLine, setContextWordLine] = useState(undefined); /*useEffect(() => { if (showSolutions && userSolutions[exerciseIndex].shuffleMaps) { setShuffleMaps(userSolutions[exerciseIndex].shuffleMaps as ShuffleMap[]) } }, [showSolutions])*/ useEffect(() => { if (hasExamEnded && exerciseIndex === -1) { setExerciseIndex(exerciseIndex + 1); } }, [hasExamEnded, exerciseIndex, setExerciseIndex]); const confirmFinishModule = (keepGoing?: boolean) => { if (!keepGoing) { setShowBlankModal(false); return; } onFinish(userSolutions); }; const getExercise = () => { if (exerciseIndex === -1) { return undefined; } let exercise = exam.parts[partIndex]?.exercises[exerciseIndex]; if (!exercise) return undefined; exercise = { ...exercise, userSolutions: userSolutions.find((x) => x.exercise === exercise.id)?.solutions || [], }; /*if (exam.shuffle && exercise.type === "multipleChoice") { if (shuffleMaps.length == 0 && !showSolutions) { console.log("Shuffling answers"); const newShuffleMaps: ShuffleMap[] = []; exercise.questions = exercise.questions.map(question => { const options = [...question.options]; let shuffledOptions = [...options].sort(() => Math.random() - 0.5); const newOptions = options.map((option, index) => ({ id: option.id, text: shuffledOptions[index].text })); const optionMapping = options.reduce<{ [key: string]: string }>((acc, originalOption) => { const shuffledPosition = newOptions.find(newOpt => newOpt.text === originalOption.text)?.id; if (shuffledPosition) { acc[shuffledPosition] = originalOption.id; } return acc; }, {}); newShuffleMaps.push({ id: question.id, map: optionMapping }); return { ...question, options: newOptions }; }); setShuffleMaps(newShuffleMaps); } else { console.log("Retrieving shuffles"); exercise.questions = exercise.questions.map(question => { const questionShuffleMap = shuffleMaps.find(map => map.id === question.id); if (questionShuffleMap) { const newOptions = question.options.map(option => ({ id: option.id, text: question.options.find(o => questionShuffleMap.map[o.id] === option.id)?.text || option.text })); return { ...question, options: newOptions }; } return question; }); } } else if (exam.shuffle && exercise.type === "fillBlanks" && typeCheckWordsMC(exercise.words)) { if (shuffleMaps.length === 0 && !showSolutions) { const newShuffleMaps: ShuffleMap[] = []; exercise.words = exercise.words.map(word => { if ('options' in word) { const options = { ...word.options }; const originalKeys = Object.keys(options); const shuffledKeys = [...originalKeys].sort(() => Math.random() - 0.5); const newOptions = shuffledKeys.reduce((acc, key, index) => { acc[key as keyof typeof options] = options[originalKeys[index] as keyof typeof options]; return acc; }, {} as { [key in keyof typeof options]: string }); const optionMapping = originalKeys.reduce((acc, key, index) => { acc[key as keyof typeof options] = shuffledKeys[index]; return acc; }, {} as { [key in keyof typeof options]: string }); newShuffleMaps.push({ id: word.id, map: optionMapping }); return { ...word, options: newOptions }; } return word; }); setShuffleMaps(newShuffleMaps); } } */ return exercise; }; useEffect(() => { setCurrentExercise(getExercise()); // eslint-disable-next-line react-hooks/exhaustive-deps }, [partIndex, exerciseIndex]); useEffect(() => { const regex = /.*?['"](.*?)['"] in line (\d+)\?$/; if (currentExercise && currentExercise.type === "multipleChoice") { const match = currentExercise.questions[storeQuestionIndex].prompt.match(regex); if (match) { const word = match[1]; const originalLineNumber = match[2]; if (word !== contextWord) { setContextWord(word); } const updatedPrompt = currentExercise.questions[storeQuestionIndex].prompt.replace( `in line ${originalLineNumber}`, `in line ${contextWordLine || originalLineNumber}`, ); currentExercise.questions[storeQuestionIndex].prompt = updatedPrompt; } else { setContextWord(undefined); } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [storeQuestionIndex, contextWordLine, exerciseIndex, partIndex]); //, shuffleMaps]); const nextExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); } if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") { setMultipleChoicesDone((prev) => [ ...prev.filter((x) => x.id !== currentExercise!.id), {id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex}, ]); } setStoreQuestionIndex(0); if (exerciseIndex + 1 < exam.parts[partIndex].exercises.length && !hasExamEnded) { setExerciseIndex(exerciseIndex + 1); return; } if (partIndex + 1 < exam.parts.length && !hasExamEnded) { setPartIndex(partIndex + 1); setExerciseIndex(!!exam.parts[partIndex + 1].context ? -1 : 0); return; } if ( solution && ![...userSolutions.filter((x) => x.exercise !== solution?.exercise).map((x) => x.score.missing), solution?.score.missing].every( (x) => x === 0, ) && !showSolutions && !editing && !hasExamEnded ) { setShowBlankModal(true); return; } setHasExamEnded(false); if (solution) { let stat = {...solution, module: "level" as Module, exam: exam.id}; /*if (exam.shuffle) { stat.shuffleMaps = shuffleMaps }*/ onFinish([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...stat}]); } else { onFinish(userSolutions); } }; const previousExercise = (solution?: UserSolution) => { scrollToTop(); if (solution) { setUserSolutions([...userSolutions.filter((x) => x.exercise !== solution.exercise), {...solution, module: "level", exam: exam.id}]); } if (storeQuestionIndex > 0 || currentExercise?.type == "fillBlanks") { setMultipleChoicesDone((prev) => [ ...prev.filter((x) => x.id !== currentExercise!.id), {id: currentExercise!.id, amount: currentExercise?.type == "fillBlanks" ? currentExercise.words.length - 1 : storeQuestionIndex}, ]); } setStoreQuestionIndex(0); setExerciseIndex(exerciseIndex - 1); }; const calculateExerciseIndex = () => { if (partIndex === 0) return ( (exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => acc + curr.amount, 0) ); const exercisesPerPart = exam.parts.map((x) => x.exercises.length); const exercisesDone = exercisesPerPart.filter((_, index) => index < partIndex).reduce((acc, curr) => curr + acc, 0); return ( exercisesDone + (exerciseIndex === -1 ? 0 : exerciseIndex + 1) + storeQuestionIndex + multipleChoicesDone.reduce((acc, curr) => { return acc + curr.amount; }, 0) ); }; const renderText = () => (
<>

Please read the following excerpt attentively, you will then be asked questions about the text you've read.

You will be allowed to read the text while doing the exercises
); const partLabel = () => { if (currentExercise?.type === "fillBlanks" && typeCheckWordsMC(currentExercise.words)) return `Part ${partIndex + 1} (Questions ${currentExercise.words[0].id} - ${ currentExercise.words[currentExercise.words.length - 1].id })\n\n${currentExercise.prompt}`; if (currentExercise?.type === "multipleChoice") return `Part ${partIndex + 1} (Questions ${currentExercise.questions[0].id} - ${ currentExercise.questions[currentExercise.questions.length - 1].id })\n\n${currentExercise.prompt}`; }; return ( <>
x.exercises))} disableTimer={showSolutions || editing} />
-1 && exerciseIndex > -1 && !!exam.parts[partIndex].context && "grid grid-cols-2 gap-4", )}> {partIndex > -1 && !!exam.parts[partIndex].context && renderText()} {exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && !showSolutions && !editing && currentExercise && renderExercise(currentExercise, exam.id, nextExercise, previousExercise)} {exerciseIndex > -1 && partIndex > -1 && exerciseIndex < exam.parts[partIndex].exercises.length && (showSolutions || editing) && renderSolution(exam.parts[partIndex].exercises[exerciseIndex], nextExercise, previousExercise)}
{exerciseIndex === -1 && partIndex > 0 && (
)} {exerciseIndex === -1 && partIndex === 0 && ( )}
); }