505 lines
16 KiB
TypeScript
505 lines
16 KiB
TypeScript
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<React.SetStateAction<number | undefined>>;
|
|
}) {
|
|
const textRef = useRef<HTMLDivElement>(null);
|
|
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
|
|
const [lineHeight, setLineHeight] = useState<number>(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<HTMLSpanElement>("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 (
|
|
<div className="flex flex-col gap-2 w-full">
|
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
{!!part.context &&
|
|
part.context
|
|
.split(/\n|(\\n)/g)
|
|
.filter((x) => x && x.length > 0 && x !== "\\n")
|
|
.map((line, index) => (
|
|
<Fragment key={index}>
|
|
<p key={index}>{line}</p>
|
|
</Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-2 w-full">
|
|
<div className="border border-mti-gray-dim w-full rounded-full opacity-10" />
|
|
<div className="flex mt-2">
|
|
<div ref={textRef} className="h-fit ml-2 flex flex-col gap-4">
|
|
{part.context!.split("\n\n").map((line, index) => {
|
|
return (
|
|
<p key={`line-${index}`}>
|
|
<span className="mr-6">{index + 1}</span>
|
|
{line}
|
|
</p>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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<Exercise>();
|
|
|
|
const scrollToTop = () => Array.from(document.getElementsByTagName("body")).forEach((body) => body.scrollTo(0, 0));
|
|
|
|
const [contextWord, setContextWord] = useState<string | undefined>(undefined);
|
|
const [contextWordLine, setContextWordLine] = useState<number | undefined>(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 = () => (
|
|
<div className={clsx("flex flex-col gap-6 w-full bg-mti-gray-seasalt rounded-xl mt-4 relative py-8 px-16")}>
|
|
<>
|
|
<div className="flex flex-col w-full gap-2">
|
|
<h4 className="text-xl font-semibold">
|
|
Please read the following excerpt attentively, you will then be asked questions about the text you've read.
|
|
</h4>
|
|
<span className="text-base">You will be allowed to read the text while doing the exercises</span>
|
|
</div>
|
|
<TextComponent part={exam.parts[partIndex]} contextWord={contextWord} setContextWordLine={setContextWordLine} />
|
|
</>
|
|
</div>
|
|
);
|
|
|
|
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 (
|
|
<>
|
|
<div className="flex flex-col h-full w-full gap-8 items-center">
|
|
<BlankQuestionsModal isOpen={showBlankModal} onClose={confirmFinishModule} />
|
|
<ModuleTitle
|
|
partLabel={partLabel()}
|
|
minTimer={exam.minTimer}
|
|
exerciseIndex={calculateExerciseIndex()}
|
|
module="level"
|
|
totalExercises={countExercises(exam.parts.flatMap((x) => x.exercises))}
|
|
disableTimer={showSolutions || editing}
|
|
/>
|
|
<div
|
|
className={clsx(
|
|
"mb-20 w-full",
|
|
partIndex > -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)}
|
|
</div>
|
|
{exerciseIndex === -1 && partIndex > 0 && (
|
|
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
|
<Button
|
|
color="purple"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setExerciseIndex(exam.parts[partIndex - 1].exercises.length - 1);
|
|
setPartIndex(partIndex - 1);
|
|
}}
|
|
className="max-w-[200px] w-full">
|
|
Back
|
|
</Button>
|
|
|
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
Next
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{exerciseIndex === -1 && partIndex === 0 && (
|
|
<Button color="purple" onClick={() => nextExercise()} className="max-w-[200px] self-end w-full">
|
|
Start now
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|