ENCOA-107, ENCOA-115 Fixed completion percentage, brought back the line numbers, 'Level Exam' was replaced by 'Placement Test', Next/Back instructions with quotes, 'Submit' on last level question
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { LevelPart } from "@/interfaces/exam";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
part: LevelPart,
|
||||
@@ -9,78 +9,65 @@ interface Props {
|
||||
|
||||
const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine }) => {
|
||||
const textRef = useRef<HTMLDivElement>(null);
|
||||
const [lineNumbers, setLineNumbers] = useState<number[]>([]);
|
||||
const [lineHeight, setLineHeight] = useState<number>(0);
|
||||
|
||||
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.whiteSpace = 'pre-wrap';
|
||||
offscreenElement.style.wordWrap = 'break-word';
|
||||
offscreenElement.style.textAlign = computedStyle.textAlign as CanvasTextAlign;
|
||||
|
||||
const paragraphs = part.context!.split('\n\n');
|
||||
let currentLine = 1;
|
||||
let contextWordLine: number | null = null;
|
||||
const paragraphLineStarts: number[] = [];
|
||||
|
||||
paragraphs.forEach((paragraph, pIndex) => {
|
||||
const p = document.createElement('p');
|
||||
p.style.margin = '0';
|
||||
p.style.padding = '0';
|
||||
|
||||
paragraph.split(/(\s+)/).forEach((word: string) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = word;
|
||||
p.appendChild(span);
|
||||
});
|
||||
|
||||
offscreenElement.appendChild(p);
|
||||
|
||||
if (pIndex < paragraphs.length - 1) {
|
||||
const gap = document.createElement('div');
|
||||
gap.style.height = '16px'; // gap-4
|
||||
offscreenElement.appendChild(gap);
|
||||
}
|
||||
const textContent = textRef.current.textContent || '';
|
||||
textContent.split(/(\s+)/).forEach((word: string) => {
|
||||
const span = document.createElement('span');
|
||||
span.textContent = word;
|
||||
offscreenElement.appendChild(span);
|
||||
});
|
||||
|
||||
document.body.appendChild(offscreenElement);
|
||||
|
||||
const lines: string[][] = [[]];
|
||||
let currentLine = 1;
|
||||
let currentLineTop: number | undefined;
|
||||
const elements = offscreenElement.querySelectorAll('p, div');
|
||||
let contextWordLine: number | null = null;
|
||||
|
||||
elements.forEach((element) => {
|
||||
if (element.tagName === 'P') {
|
||||
const spans = element.querySelectorAll<HTMLSpanElement>('span');
|
||||
paragraphLineStarts.push(currentLine);
|
||||
const firstChild = offscreenElement.firstChild as HTMLElement;
|
||||
if (firstChild) {
|
||||
currentLineTop = firstChild.getBoundingClientRect().top;
|
||||
}
|
||||
|
||||
spans.forEach(span => {
|
||||
const rect = span.getBoundingClientRect();
|
||||
const top = rect.top;
|
||||
const spans = offscreenElement.querySelectorAll<HTMLSpanElement>('span');
|
||||
|
||||
if (currentLineTop === undefined || top > currentLineTop) {
|
||||
if (currentLineTop !== undefined) {
|
||||
currentLine++;
|
||||
}
|
||||
currentLineTop = top;
|
||||
}
|
||||
spans.forEach(span => {
|
||||
const rect = span.getBoundingClientRect();
|
||||
const top = rect.top;
|
||||
|
||||
if (contextWord && contextWordLine === null && span.textContent?.includes(contextWord)) {
|
||||
contextWordLine = currentLine;
|
||||
}
|
||||
});
|
||||
} else if (element.tagName === 'DIV') { // Gap
|
||||
if (currentLineTop !== undefined && top > currentLineTop) {
|
||||
currentLine++;
|
||||
currentLineTop = undefined;
|
||||
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);
|
||||
}
|
||||
@@ -131,10 +118,15 @@ const TextComponent: React.FC<Props> = ({ part, contextWord, setContextWordLine
|
||||
|
||||
return (
|
||||
<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 className="flex-shrink-0 w-8 pr-2">
|
||||
{lineNumbers.map(num => (
|
||||
<div key={num} className="text-gray-400 flex justify-end" style={{ lineHeight: `${lineHeight}px` }}>
|
||||
{num}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div ref={textRef} className="h-fit whitespace-pre-wrap ml-2">
|
||||
<div dangerouslySetInnerHTML={{ __html: part.context! }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Exercise, FillBlanksMCOption, LevelExam, MultipleChoiceExercise, Multip
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import { countExercises } from "@/utils/moduleUtils";
|
||||
import clsx from "clsx";
|
||||
import { use, useEffect, useState } from "react";
|
||||
import { use, useEffect, useMemo, useState } from "react";
|
||||
import TextComponent from "./TextComponent";
|
||||
import PartDivider from "./PartDivider";
|
||||
import Timer from "@/components/Medium/Timer";
|
||||
@@ -56,6 +56,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
const [showQuestionsModal, setShowQuestionsModal] = useState(false);
|
||||
const [continueAnyways, setContinueAnyways] = useState(false);
|
||||
const [textRender, setTextRender] = useState(false);
|
||||
const [changedPrompt, setChangedPrompt] = useState(false);
|
||||
|
||||
const [seenParts, setSeenParts] = useState<number[]>(showSolutions ? exam.parts.map((_, index) => index) : [0]);
|
||||
|
||||
@@ -66,8 +67,6 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
onClose: function (x: boolean | undefined) { if (x) { setShowQuestionsModal(false); nextExercise(); } else { setShowQuestionsModal(false) } }
|
||||
});
|
||||
|
||||
|
||||
|
||||
const [currentExercise, setCurrentExercise] = useState<Exercise>(exam.parts[0].exercises[0]);
|
||||
const [showPartDivider, setShowPartDivider] = useState<boolean>(typeof exam.parts[0].intro === "string" && !showSolutions);
|
||||
|
||||
@@ -120,37 +119,6 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
}, [partIndex, exerciseIndex, questionIndex]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||
if (
|
||||
exerciseIndex !== -1 && currentExercise &&
|
||||
currentExercise.type === "multipleChoice" &&
|
||||
currentExercise.questions[questionIndex] &&
|
||||
currentExercise.questions[questionIndex].prompt &&
|
||||
exam.parts[partIndex].context
|
||||
) {
|
||||
const match = currentExercise.questions[questionIndex].prompt.match(regex);
|
||||
if (match) {
|
||||
const word = match[1];
|
||||
const originalLineNumber = match[2];
|
||||
|
||||
if (word !== contextWord) {
|
||||
setContextWord(word);
|
||||
}
|
||||
|
||||
const updatedPrompt = currentExercise.questions[questionIndex].prompt.replace(
|
||||
`in line ${originalLineNumber}`,
|
||||
`in line ${contextWordLine || originalLineNumber}`
|
||||
);
|
||||
|
||||
currentExercise.questions[questionIndex].prompt = updatedPrompt;
|
||||
} else {
|
||||
setContextWord(undefined);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentExercise, questionIndex]);
|
||||
|
||||
const nextExercise = (solution?: UserSolution) => {
|
||||
scrollToTop();
|
||||
|
||||
@@ -341,6 +309,38 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const regex = /.*?['"](.*?)['"] in line (\d+)\?$/;
|
||||
if (
|
||||
exerciseIndex !== -1 && currentExercise &&
|
||||
currentExercise.type === "multipleChoice" &&
|
||||
currentExercise.questions[questionIndex] &&
|
||||
currentExercise.questions[questionIndex].prompt &&
|
||||
exam.parts[partIndex].context
|
||||
) {
|
||||
const match = currentExercise.questions[questionIndex].prompt.match(regex);
|
||||
if (match) {
|
||||
const word = match[1];
|
||||
const originalLineNumber = match[2];
|
||||
|
||||
if (word !== contextWord) {
|
||||
setContextWord(word);
|
||||
}
|
||||
|
||||
const updatedPrompt = currentExercise.questions[questionIndex].prompt.replace(
|
||||
`in line ${originalLineNumber}`,
|
||||
`in line ${contextWordLine || originalLineNumber}`
|
||||
);
|
||||
|
||||
currentExercise.questions[questionIndex].prompt = updatedPrompt;
|
||||
setChangedPrompt(true);
|
||||
} else {
|
||||
setContextWord(undefined);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentExercise, questionIndex, contextWordLine]);
|
||||
|
||||
useEffect(() => {
|
||||
if (continueAnyways) {
|
||||
setContinueAnyways(false);
|
||||
@@ -375,6 +375,24 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
}
|
||||
|
||||
|
||||
const memoizedRender = useMemo(() => {
|
||||
setChangedPrompt(false);
|
||||
return (
|
||||
<>
|
||||
{textRender ?
|
||||
renderText() :
|
||||
<>
|
||||
{exam.parts[partIndex].context && renderText()}
|
||||
{(showSolutions || editing) ?
|
||||
renderSolution(currentExercise, nextExercise, previousExercise)
|
||||
:
|
||||
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)
|
||||
}
|
||||
</>
|
||||
}
|
||||
</>)
|
||||
}, [textRender, currentExercise, changedPrompt]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={clsx("flex flex-col h-full w-full gap-8 items-center", showPartDivider && "justify-center")}>
|
||||
@@ -429,18 +447,7 @@ export default function Level({ exam, showSolutions = false, onFinish, editing =
|
||||
"mb-20 w-full",
|
||||
!!exam.parts[partIndex].context && !textRender && "grid grid-cols-2 gap-4",
|
||||
)}>
|
||||
|
||||
{textRender ?
|
||||
renderText() :
|
||||
<>
|
||||
{exam.parts[partIndex].context && renderText()}
|
||||
{(showSolutions || editing) ?
|
||||
renderSolution(currentExercise, nextExercise, previousExercise)
|
||||
:
|
||||
renderExercise(currentExercise, exam.id, nextExercise, previousExercise)
|
||||
}
|
||||
</>
|
||||
}
|
||||
{memoizedRender}
|
||||
</div>
|
||||
{/*exerciseIndex === -1 && partIndex > 0 && (
|
||||
<div className="self-end flex justify-between w-full gap-8 absolute bottom-8 left-0 px-8">
|
||||
|
||||
Reference in New Issue
Block a user