feat: add notes functionality to exam state and update UI for note-taking

This commit is contained in:
LaurensBartelds committed 2026-04-22 22:48:24 +02:00
1 parent d0b1ac0145
commit c4c1e1a45a
5 files changed
+90 -31

No files matched your search

+31 -10
View File
@@ -14,10 +14,11 @@ interface Props {
onExit: () => void
}
export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Props) {
export default function ExamRunner({ exam, state, setState, onSubmit, onExit }: Readonly<Props>) {
const { currentIdx, answers, flags, startedAt, feedbackMode } = state
const q = exam.questions[currentIdx]
const total = exam.questions.length
const notes = state.notes ?? ''
const [revealed, setRevealed] = useState(() => !!state.revealed?.[q.id])
useEffect(() => { setRevealed(!!state.revealed?.[q.id]) }, [currentIdx, q.id, state.revealed])
@@ -31,8 +32,14 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
const toggleFlag = useCallback(() =>
setState((s) => ({ ...s, flags: { ...s.flags, [q.id]: !s.flags[q.id] } })), [q.id])
const updateNotes = (value: string) =>
setState((s) => ({ ...s, notes: value.slice(0, 1000) }))
const checkAnswer = () => {
setState((s) => ({ ...s, revealed: { ...(s.revealed ?? {}), [q.id]: true } }))
setState((s) => ({
...s,
revealed: s.revealed ? { ...s.revealed, [q.id]: true } : { [q.id]: true },
}))
setRevealed(true)
}
@@ -58,13 +65,13 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
if (e.key === 'ArrowLeft' || e.key === 'k') goTo(currentIdx - 1)
if (e.key === 'f') toggleFlag()
if (/^[1-9]$/.test(e.key) && q.type === 'multiple-choice' && q.choices) {
const ci = parseInt(e.key, 10) - 1
const ci = Number.parseInt(e.key, 10) - 1
const choices = q.choices
if (choices[ci]) setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: choices[ci].id } }))
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
globalThis.addEventListener('keydown', handler)
return () => globalThis.removeEventListener('keydown', handler)
}, [currentIdx, q, goTo, toggleFlag, setState])
const graded = feedbackMode === 'immediate' && revealed
@@ -90,6 +97,12 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
}).length
const totalPossible = exam.questions.reduce((acc, qq) => acc + (qq.points ?? 1), 0)
let answerText = ''
if (!Array.isArray(q.answer) && typeof q.answer === 'string') {
answerText = q.choices?.find((c) => c.id === q.answer)?.text ?? q.answer
} else if (Array.isArray(q.answer)) {
answerText = q.answer.map((id) => q.choices?.find((c) => c.id === id)?.text ?? id).join(', ')
}
return (
<div className="exam-shell">
@@ -145,11 +158,7 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
{!result.correct && q.answer && q.type !== 'match' && q.type !== 'order' && (
<p>
<strong>Answer:</strong>{' '}
<Tex>
{Array.isArray(q.answer)
? (q.answer as string[]).map((id) => q.choices?.find((c) => c.id === id)?.text ?? id).join(', ')
: (q.choices?.find((c) => c.id === q.answer)?.text ?? String(q.answer))}
</Tex>
<Tex>{answerText}</Tex>
</p>
)}
{q.explanation && <p><Tex>{q.explanation}</Tex></p>}
@@ -201,6 +210,18 @@ export default function ExamRunner({ exam, state, setState, onSubmit, onExit }:
{feedbackMode === 'immediate' ? 'Immediate feedback' : 'Grade at end'}
</div>
</div>
<div className="meta-block notes-block">
<div className="meta-label">Notes</div>
<textarea
className="textarea notes-pad"
value={notes}
onChange={(e) => updateNotes(e.target.value)}
placeholder="Add short notes while you work..."
maxLength={1000}
aria-label="Exam notes"
/>
<div className="notes-count mono">{notes.length}/1000</div>
</div>
{exam.author && (
<div className="meta-block">
<div className="meta-label">Author</div>