All files / src/utils/components TinyMCEAnswerEditor.jsx

37.5% Statements 9/24
13.33% Branches 2/15
25% Functions 2/8
40.9% Lines 9/22

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                                                                                          9x 3x 3x 3x 3x   3x                 3x 3x                                                                                                             3x                                                              
import React, { useCallback, useMemo, useRef } from "react";
import { Editor } from "@tinymce/tinymce-react";
import { useTheme } from "@mui/material/styles";
import { Box } from "@mui/material";
import "tinymce/tinymce";
import "tinymce/icons/default";
import "tinymce/themes/silver";
import "tinymce/plugins/autoresize";
import "tinymce/models/dom/model";
// This editor has a visible toolbar (unlike TinyMCETextEditorReadOnly, which
// has none) — with `skin: false` below, TinyMCE expects the caller to supply
// the skin CSS itself; without it the toolbar chrome has no styling and the
// editor container never becomes visible.
import "tinymce/skins/ui/oxide/skin.min.css";
import "tinymce/skins/ui/oxide/content.min.css";
import "@/lib/tinymce/plugins/math-formula";
 
/**
 * TinyMCE's silver theme reveals its chrome (`.tox-tinymce`, hidden by
 * default while it measures layout) once its own post-init promise chain
 * resolves. With `skin: false` that promise can get stuck — same issue
 * documented in TinyMCETextEditorReadOnly.jsx — leaving the editor invisible
 * even though it initialized fine. Revealing by hand here sidesteps it.
 */
function revealEditor(editor) {
    const container = editor?.getContainer?.();
    if (!container) return;
    container.style.visibility = "visible";
}
 
/**
 * Lightweight EDITABLE TinyMCE field for a student's free-response answer:
 * basic text formatting plus the math-formula ("matheditor") plugin, so an
 * answer can mix typed text with a rendered math expression instead of being
 * LaTeX-only (the previous <math-field> input). Deliberately does not carry
 * the full authoring toolbar (images/tables/graphs) from TinyMCETextEditor —
 * those don't apply to answering a question, only to authoring one.
 *
 * @param {Object} props
 * @param {string} props.content - Current answer HTML.
 * @param {(html: string) => void} props.onChange - Called with the new HTML on every edit.
 * @param {string} [props.backgroundColor] - Solid iframe background (matches the parent question card).
 * @param {string} [props.fontSize="16px"] - Overrides the default answer text size, e.g. to match
 * the question stem/choices it's answering (see studentQuestionSurfaces.js's STUDENT_QUESTION_FONT_SIZE).
 */
const TinyMCEAnswerEditor = ({ content, onChange, backgroundColor, fontSize = "16px" }) => {
    const theme = useTheme();
    const editorRef = useRef(null);
    const textColor = theme.palette.text.primary;
    const surfaceBg = backgroundColor ?? theme.palette.background.paper;
 
    const handleEditorChange = useCallback(
        (html) => {
            const plainText = html.replace(/<[^>]+>/g, "").replace(/&nbsp;/gi, " ");
            const hasFormula = /class="mfe-formula"/.test(html);
            onChange(plainText.trim() || hasFormula ? html : "");
        },
        [onChange]
    );
 
    const editorInit = useMemo(
        () => ({
            license_key: "gpl",
            branding: false,
            skin: false,
            content_css: false,
            menubar: false,
            statusbar: false,
            toolbar: "bold italic underline | matheditor",
            forced_root_block: "p",
            plugins: ["autoresize", "matheditor"],
            autoresize_bottom_margin: 12,
            autoresize_min_height: 80,
            min_height: 80,
            object_resizing: false,
            placeholder: "Type your answer here…",
            content_style: `html, body {
                    background-color: ${surfaceBg} !important;
                    color: ${textColor} !important;
                    line-height: 1.6;
                    margin: 0;
                    padding: 10px 12px;
                    font-size: ${fontSize};
                }
                p { margin: 0 0 4px; }
                .mfe-formula, .mfe-formula * {
                    background: transparent !important;
                    background-color: transparent !important;
                }`,
            // Same allow-list as the read-only renderer (TinyMCETextEditorReadOnly) —
            // span.mfe-formula carries the rendered math markup + data-latex source.
            extended_valid_elements: [
                "span[class|style|data-latex|contenteditable|data-mce-selected]",
                "math-field[read-only|style|class|data-mce-selected]",
            ].join(","),
            custom_elements: "math-field",
            setup: (editor) => {
                editor.on("init", () => revealEditor(editor));
 
                editor.on("keydown", (e) => {
                    // Block leading-space-only input, same guard as TinyMCETextEditor,
                    // so an "answer" of only spaces can't be submitted as if it were real content.
                    const isSpaceKey = e.key === " " || e.keyCode === 32;
                    const hasContent = editor
                        .getContent()
                        .replace(/<[^>]+>/g, "")
                        .trim();
                    if (isSpaceKey && !hasContent) {
                        e.preventDefault();
                    }
                });
            },
        }),
        [surfaceBg, textColor, fontSize]
    );
 
    return (
        <Box
            className="free-response-tinymce"
            sx={{
                "& .tox-tinymce": {
                    border: "1px solid",
                    borderColor: "divider",
                    borderRadius: 1,
                    backgroundColor: `${surfaceBg} !important`,
                    // Belt-and-suspenders for revealEditor() above.
                    visibility: "visible !important",
                },
                "& .tox-editor-container, & .tox-edit-area, & .tox-edit-area__iframe, & iframe": {
                    backgroundColor: `${surfaceBg} !important`,
                },
            }}
        >
            <Editor
                key={theme.palette.mode}
                value={content}
                onEditorChange={handleEditorChange}
                onInit={(_, editor) => {
                    editorRef.current = editor;
                }}
                init={editorInit}
            />
        </Box>
    );
};
 
export default TinyMCEAnswerEditor;