Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 | 4x 33x 33x 33x 2x 31x 4x 21x 1x 1x 1x 1x 21x 1x 1x 1x 1x 4x 46x 46x 46x 46x 46x 46x 21x 46x 70x 70x 70x 70x 70x 70x 70x 70x 70x 46x 46x 70x 46x 42x 21x 21x 21x 21x 21x 3x 3x 2x 2x 2x 3x 21x 21x 21x 21x 21x 21x 21x 46x 46x 25x 25x 25x 46x 42x 21x 21x 21x 21x 21x 21x 21x 21x 46x 42x 21x 4x 4x 10x 2x 3x 2x 8x 21x 21x 46x 21x 21x 21x 21x 32x 46x 4x | import React, { useEffect, useRef, useState } from "react";
import { Box, Paper, Typography } from "@mui/material";
import DragIndicatorIcon from "@mui/icons-material/DragIndicator";
import tinyMCEUtility from "@/utils/helpers/tinyMCEUtility";
import TinyMCETextEditorReadOnly from "@/utils/components/TinyMCETextEditorReadOnly";
import { sanitizeRichText } from "@/utils/helpers/sanitizeHtml";
const unwrapBlock = (html) => {
const el = document.createElement("div");
el.innerHTML = html;
if (el.children.length === 1 && el.children[0].tagName === "P") {
return el.children[0].innerHTML;
}
return html;
};
const injectMathLiveCSS = () => {
if (!document.querySelector('link[href*="mathlive-fonts"]')) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://cdn.jsdelivr.net/npm/mathlive@0.108.2/mathlive-fonts.css";
document.head.appendChild(link);
}
if (!document.querySelector('link[href*="mathlive-static"]')) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://cdn.jsdelivr.net/npm/mathlive@0.108.2/mathlive-static.css";
document.head.appendChild(link);
}
};
const DragAndDrop = ({ isQuestionText, isChoices, setChoices, isBlanks, setBlanks }) => {
const { regexDropHere } = tinyMCEUtility();
const [editor, setEditor] = useState(null);
const iBlanksRef = useRef(isBlanks);
useEffect(() => {
iBlanksRef.current = isBlanks;
});
useEffect(() => {
injectMathLiveCSS();
}, []);
const buildDropZoneHTML = (index) => {
const blank = isBlanks[index];
const hasAnswer = !!blank?.answer;
const answerContent = hasAnswer ? unwrapBlock(blank.answer) : "Drop here";
const borderColor = hasAnswer ? "#2e7d32" : "#9e9e9e";
const bgColor = hasAnswer ? "#e8f5e9" : "#f5f5f5";
const textColor = hasAnswer ? "#2e7d32" : "#757575";
const borderStyle = hasAnswer ? "solid" : "dashed";
const clearBtn = hasAnswer
? `<span data-clear-index="${index}" style="position:absolute;top:-8px;right:-8px;` +
`display:inline-flex;align-items:center;justify-content:center;` +
`width:16px;height:16px;border-radius:50%;background-color:#d32f2f;` +
`color:white;font-size:11px;cursor:pointer;line-height:1;z-index:1;">✕</span>`
: "";
return (
`<span data-drop-index="${index}" style="position:relative;display:inline-block;` +
`padding:2px 16px;margin:0 8px;border-radius:8px;` +
`border:2px ${borderStyle} ${borderColor};background-color:${bgColor};` +
`vertical-align:middle;white-space:nowrap;line-height:normal;font-size:inherit;` +
`font-weight:${hasAnswer ? 500 : 400};color:${textColor};cursor:pointer;">` +
`${answerContent}${clearBtn}` +
`</span>`
);
};
const buildContent = () => {
let blankIndex = 0;
return isQuestionText.replace(regexDropHere, () => buildDropZoneHTML(blankIndex++));
};
// Drag/drop — delegate to the body element itself, which is stable across TinyMCE setContent
// calls (only innerHTML changes, not the body node). This avoids the race where per-element
// listeners attached via querySelectorAll run before TinyMCE has updated the DOM.
useEffect(() => {
if (!editor) return;
const body = editor.getBody();
Iif (!body) return;
const onDragOver = (e) => {
const zone = e.target.closest?.("[data-drop-index]");
if (!zone) return;
e.preventDefault();
zone.style.borderColor = "#1976d2";
zone.style.backgroundColor = "#e3f2fd";
zone.style.borderStyle = "dashed";
zone.style.color = "#1976d2";
};
const onDragLeave = (e) => {
const zone = e.target.closest?.("[data-drop-index]");
if (!zone || zone.contains(e.relatedTarget)) return;
const index = parseInt(zone.getAttribute("data-drop-index"), 10);
const hasAnswer = !!iBlanksRef.current?.[index]?.answer;
zone.style.borderColor = hasAnswer ? "#2e7d32" : "#9e9e9e";
zone.style.backgroundColor = hasAnswer ? "#e8f5e9" : "#f5f5f5";
zone.style.borderStyle = hasAnswer ? "solid" : "dashed";
zone.style.color = hasAnswer ? "#2e7d32" : "#757575";
};
const onDrop = (e) => {
const zone = e.target.closest?.("[data-drop-index]");
if (!zone) return;
e.preventDefault();
const index = parseInt(zone.getAttribute("data-drop-index"), 10);
const value = e.dataTransfer.getData("text/plain");
setBlanks((prev) => prev.map((item, idx) => (idx === index ? { ...item, answer: value } : item)));
};
body.addEventListener("dragover", onDragOver);
body.addEventListener("dragleave", onDragLeave);
body.addEventListener("drop", onDrop);
return () => {
body.removeEventListener("dragover", onDragOver);
body.removeEventListener("dragleave", onDragLeave);
body.removeEventListener("drop", onDrop);
};
}, [editor, setBlanks]);
// Re-run MathJax after every render so math inside drop zones renders correctly.
useEffect(() => {
if (!editor) return;
const body = editor.getBody();
Iif (!body || !window.MathJax?.typesetPromise) return;
window.MathJax.typesetPromise([body]).catch(() => {});
});
// Block all keyboard input — TinyMCE 7 keeps the body contenteditable even in readonly mode,
// so the browser still allows text insertion without explicit event blocking.
useEffect(() => {
if (!editor) return;
const blockInput = (e) => e.preventDefault();
editor.on("keydown", blockInput);
editor.on("keypress", blockInput);
editor.on("beforeinput", blockInput);
return () => {
editor.off("keydown", blockInput);
editor.off("keypress", blockInput);
editor.off("beforeinput", blockInput);
};
}, [editor]);
// Use TinyMCE's event system for X button clicks — avoids the race where editor.setContent
// updates the DOM after the useEffect that attaches per-element listeners has already run.
useEffect(() => {
if (!editor) return;
const handleClick = (e) => {
let el = e.target;
while (el) {
if (el.hasAttribute?.("data-clear-index")) {
const index = parseInt(el.getAttribute("data-clear-index"), 10);
setBlanks((prev) => prev.map((item, idx) => (idx === index ? { ...item, answer: "" } : item)));
return;
}
el = el.parentElement;
}
};
editor.on("click", handleClick);
return () => editor.off("click", handleClick);
}, [editor, setBlanks]);
// Rebuild blanks POSITION-ALIGNED on every question-text change:
// id === position === array index, preserving answers by position.
// The student side regenerates blank ids by `__blank__` appearance
// (0..N-1), and the backend grades drag-and-drop by id-sorted {id,answer}
// equality — so the stored correctAnswer ids MUST be position-based. The
// previous append-only init left id != position after a mid-text blank
// insert/delete, so a correct student drag graded as wrong. (EI-2906)
useEffect(() => {
const parts = isQuestionText.split(regexDropHere);
const blanksLength = Math.max(parts.length - 1, 0);
setBlanks((prev) => {
const prevArr = Array.isArray(prev) ? prev : [];
return Array.from({ length: blanksLength }, (_, i) => ({
id: i,
answer: prevArr[i]?.answer ?? "",
}));
});
}, [isQuestionText]);
return (
<Box className="drag-and-drop" sx={{ display: "flex", flexDirection: "column", gap: 4, width: "100%" }}>
{/* Question text rendered by TinyMCETextEditorReadOnly — same pipeline as DragDropViewOnly.
pointer-events enabled so drag events reach the drop zone spans inside the iframe. */}
<TinyMCETextEditorReadOnly
content={buildContent()}
sx={{ pointerEvents: "auto" }}
onEditorInit={setEditor}
/>
{isChoices.length > 0 && (
<Box sx={{ pt: 2, borderTop: "1px solid", borderColor: "grey.200" }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1.5 }}>
Drag these choices to the drop zones above:
</Typography>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 1.5 }}>
{isChoices.map((choiceObj, index) => (
<Paper
key={index}
elevation={2}
draggable
onDragStart={(e) => {
e.dataTransfer.setData("text/plain", choiceObj.text);
e.dataTransfer.effectAllowed = "move";
}}
sx={{
display: "flex",
alignItems: "center",
gap: 0.5,
px: 1.5,
py: 0.75,
backgroundColor: "primary.main",
color: "white",
borderRadius: 2,
cursor: "grab",
transition: "all 0.2s ease",
"&:hover": {
backgroundColor: "primary.dark",
transform: "translateY(-2px)",
boxShadow: 4,
},
"&:active": { cursor: "grabbing", transform: "scale(1.05)" },
}}
>
<DragIndicatorIcon sx={{ fontSize: 18, opacity: 0.8, flexShrink: 0 }} />
{/* Choices content sx mirrors DragDropViewOnly exactly */}
<Box
dangerouslySetInnerHTML={{ __html: sanitizeRichText(unwrapBlock(choiceObj.text)) }}
sx={{
fontWeight: 500,
fontSize: "1.2rem",
lineHeight: "normal",
"& p": { margin: 0, padding: 0 },
"& .mfe-formula": { color: "inherit" },
}}
/>
</Paper>
))}
</Box>
</Box>
)}
</Box>
);
};
export default DragAndDrop;
|