onChange(e.target.value)}
placeholder="LaTeX, e.g. 2x\ln x + x"
disabled={locked}
autoComplete="off"
spellCheck={false}
/>
Preview
{preview
?
: Live LaTeX preview appears here}
);
};
// ---------- Essay ----------
const Essay = ({ q, value = {}, onChange, locked }) => {
const text = typeof value === 'string' ? value : (value.text || '');
const wordCount = text.trim() ? text.trim().split(/\s+/).length : 0;
const minOK = !q.minWords || wordCount >= q.minWords;
return (
);
};
// ---------- Match ----------
const Match = ({ q, value = {}, onChange, locked, graded }) => {
const [activeLeft, setActiveLeft] = useState(null);
const reverseMap = useMemo(() => {
const m = {};
Object.entries(value).forEach(([l, r]) => { m[r] = l; });
return m;
}, [value]);
const handleRight = (rightId) => {
if (locked) return;
if (activeLeft) {
const next = { ...value };
// clear any existing pairing for this left or right
Object.keys(next).forEach((k) => { if (k === activeLeft || next[k] === rightId) delete next[k]; });
next[activeLeft] = rightId;
onChange(next);
setActiveLeft(null);
} else if (reverseMap[rightId]) {
// click right to clear its pairing
const next = { ...value };
delete next[reverseMap[rightId]];
onChange(next);
}
};
const handleLeft = (leftId) => {
if (locked) return;
if (value[leftId]) {
// click a paired left → clear it
const next = { ...value };
delete next[leftId];
onChange(next);
return;
}
setActiveLeft(activeLeft === leftId ? null : leftId);
};
const badge = (id, fromLeft) => {
if (fromLeft) {
const r = value[id];
if (!r) return null;
const idx = q.right.findIndex((x) => x.id === r);
return {String.fromCharCode(65 + idx)};
}
const l = reverseMap[id];
if (!l) return null;
const idx = q.left.findIndex((x) => x.id === l);
return {idx + 1};
};
const statusCls = (leftId, rightId) => {
if (!graded) return '';
const correctPair = q.answer[leftId];
if (correctPair && value[leftId] === correctPair) return ' correct';
if (value[leftId]) return ' wrong';
return '';
};
return (
Items
{q.left.map((item, i) => {
let cls = 'match-item';
if (activeLeft === item.id) cls += ' active';
else if (value[item.id]) cls += ' paired';
cls += statusCls(item.id);
return (
);
})}
Matches
{q.right.map((item, i) => {
let cls = 'match-item';
if (reverseMap[item.id]) cls += ' paired';
// for graded: color right item based on whether the left it's paired with is correct
if (graded && reverseMap[item.id]) {
const l = reverseMap[item.id];
if (q.answer[l] === item.id) cls += ' correct';
else cls += ' wrong';
}
return (
);
})}
);
};
// ---------- Order (drag to reorder) ----------
const OrderQuestion = ({ q, value, onChange, locked, graded }) => {
// initialize value as shuffled item ids on first render
const initial = useMemo(() => {
if (Array.isArray(value) && value.length === q.items.length) return value;
const ids = q.items.map((i) => i.id);
// deterministic shuffle using question id as seed
const seed = q.id.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const out = [...ids];
for (let i = out.length - 1; i > 0; i--) {
const j = (seed * (i + 7)) % (i + 1);
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}, [q.id]);
useEffect(() => {
if (!Array.isArray(value) || value.length !== q.items.length) {
onChange(initial);
}
}, []);
const list = Array.isArray(value) && value.length === q.items.length ? value : initial;
const [dragIdx, setDragIdx] = useState(null);
const [overIdx, setOverIdx] = useState(null);
const move = (from, to) => {
if (from === to || from < 0 || to < 0) return;
const next = [...list];
const [item] = next.splice(from, 1);
next.splice(to, 0, item);
onChange(next);
};
const onDragStart = (idx) => (e) => {
setDragIdx(idx);
e.dataTransfer.effectAllowed = 'move';
};
const onDragOver = (idx) => (e) => {
e.preventDefault();
setOverIdx(idx);
};
const onDrop = (idx) => (e) => {
e.preventDefault();
if (dragIdx != null && dragIdx !== idx) move(dragIdx, idx);
setDragIdx(null); setOverIdx(null);
};
const itemById = (id) => q.items.find((i) => i.id === id);
return (