All files / src/utils/components/question-bank/question-display/embedded-multiple-choice EmbeddedChoicePassage.jsx

94.87% Statements 37/39
91.66% Branches 22/24
90% Functions 9/10
96.96% Lines 32/33

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                              10x                 110x 110x 110x   110x 110x 110x 110x 197x 197x 197x 8x 189x 62x   197x                     197x               110x     110x 110x 4x 4x   4x 4x 8x 8x 1x 1x   8x 8x     4x       8x     110x                                      
import React, { useEffect, useRef } from "react";
import { Box } from "@mui/material";
import { useTheme } from "@mui/material/styles";
import { sanitizeRichText } from "@/utils/helpers/sanitizeHtml";
import tinyMCEUtility from "@/utils/helpers/tinyMCEUtility";
import { inlineEmbeddedChoiceColors } from "@/utils/components/question-bank/question-display/utils/questionCardStyles";
 
/**
 * Renders a passage that already has `[data-embedded-choice-id]` spans baked
 * into its HTML (the choice text IS the paragraph text) and recolors those
 * spans based on the current selection / revealed answer.
 *
 * - `interactive`: attaches click handlers and highlights `selectedId`.
 * - read-only (`interactive` false): highlights `correctId` when `showAnswers`.
 */
const EmbeddedChoicePassage = ({
    html,
    interactive = false,
    selectedId = null,
    correctId = null,
    showAnswers = false,
    onSelect,
    backgroundColor,
}) => {
    const theme = useTheme();
    const containerRef = useRef(null);
    const { EMBEDDED_CHOICE_ATTR } = tinyMCEUtility();
 
    const buildContent = () => {
        const container = document.createElement("div");
        container.innerHTML = sanitizeRichText(html || "");
        container.querySelectorAll(`[${EMBEDDED_CHOICE_ATTR}]`).forEach((node) => {
            const id = node.getAttribute(EMBEDDED_CHOICE_ATTR);
            let state = "default";
            if (interactive) {
                if (selectedId === id) state = "selected";
            } else if (showAnswers && correctId === id) {
                state = "correct";
            }
            const colors = inlineEmbeddedChoiceColors(theme, state);
            // A full border, not text-decoration/border-bottom — see
            // inlineEmbeddedChoiceColors' docstring: a formula choice is
            // display:inline-block, which a text-decoration on this wrapper
            // span never paints through.
            //
            // display:inline-block on this wrapper too, not plain inline: a
            // plain inline element's border follows the line box (font
            // ascent/descent), not a taller inline-block descendant, so a
            // fraction/root inside a formula choice got cut through the middle
            // by the border instead of fully enclosed by it.
            node.setAttribute(
                "style",
                `display:inline-block;vertical-align:middle;border:2px ${colors.borderStyle};` +
                    `background-color:${colors.backgroundColor};color:${colors.textColor};` +
                    `padding:1px 3px;border-radius:3px;font-weight:${state !== "default" ? 600 : 500};` +
                    `${interactive ? "cursor:pointer;" : ""}`
            );
        });
        return container.innerHTML;
    };
 
    useEffect(() => {
        if (!interactive) return undefined;
        const container = containerRef.current;
        Iif (!container) return undefined;
 
        const cleanup = [];
        container.querySelectorAll(`[${EMBEDDED_CHOICE_ATTR}]`).forEach((node) => {
            const id = node.getAttribute(EMBEDDED_CHOICE_ATTR);
            const handleClick = (e) => {
                e.stopPropagation();
                onSelect?.(id);
            };
            node.addEventListener("click", handleClick);
            cleanup.push(() => node.removeEventListener("click", handleClick));
        });
 
        Iif (window.MathJax?.typesetPromise) {
            window.MathJax.typesetPromise([container]).catch(() => {});
        }
 
        return () => cleanup.forEach((fn) => fn());
    });
 
    return (
        <Box
            ref={containerRef}
            dangerouslySetInnerHTML={{ __html: buildContent() }}
            sx={{
                width: "100%",
                fontSize: "1rem",
                lineHeight: 1.6,
                color: "text.primary",
                bgcolor: backgroundColor,
                "& p": { margin: 0, marginBottom: "0.75em" },
                "& p:last-child": { marginBottom: 0 },
                "& s, & del": { textDecoration: "line-through" },
            }}
        />
    );
};
 
export default EmbeddedChoicePassage;