All files / src/lib/tinymce/plugins/image-upload ImageUploadDialog.jsx

100% Statements 77/77
84.78% Branches 39/46
100% Functions 15/15
100% Lines 75/75

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          6x     20x                         6x 74x 74x 74x 74x 74x 74x 74x   74x 35x 13x 13x   35x 35x     74x 10x 10x 10x 10x 10x     74x   25x 23x 23x 23x 23x 23x 23x         74x 32x 2x 2x 2x       74x 1x 1x 1x     74x 14x 13x 13x 13x 13x 13x 13x   12x 14x       1x     11x               14x 1x   11x 1x 1x   10x 10x 10x 10x       9x 9x   4x 4x 4x   13x       74x         1x                               1x                 1x 1x   1x 1x                                             24x                   14x                          
import React, { useState, useRef, useCallback, useEffect } from "react";
import { Alert, Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
 
import { uploadQuestionImage } from "@/api/imageUploadHelper";
 
const IMG_TAG_STYLE = "max-width:100%;height:auto;display:inline-block;vertical-align:middle";
 
function escapeHtmlAttr(value) {
    return String(value).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
}
 
/**
 * ImageUploadDialog
 *
 * Picks an image file, uploads it to the Teacher-Student API, and inserts an
 * <img> pointing at the returned CDN URL.
 *
 * It used to inline the file as a base64 data: URI. That embedded the whole
 * image in the saved question HTML, so a 2MB photo travelled to every student
 * on every attempt and could never be cached at the edge.
 */
const ImageUploadDialog = ({ open, onClose, editorRef, replaceNode = null, onImageUpload = null }) => {
    const fileInputRef = useRef(null);
    const previewObjectUrlRef = useRef(null);
    const [previewUrl, setPreviewUrl] = useState(null);
    const [selectedFile, setSelectedFile] = useState(null);
    const [isDragOver, setIsDragOver] = useState(false);
    const [saving, setSaving] = useState(false);
    const [error, setError] = useState(null);
 
    const revokePreview = useCallback(() => {
        if (previewObjectUrlRef.current) {
            URL.revokeObjectURL(previewObjectUrlRef.current);
            previewObjectUrlRef.current = null;
        }
        setPreviewUrl(null);
        setSelectedFile(null);
    }, []);
 
    const handleClose = () => {
        revokePreview();
        setIsDragOver(false);
        setError(null);
        setSaving(false);
        onClose();
    };
 
    const loadPreview = useCallback(
        (file) => {
            if (!file || !file.type.startsWith("image/")) return;
            setError(null);
            revokePreview();
            const objectUrl = URL.createObjectURL(file);
            previewObjectUrlRef.current = objectUrl;
            setPreviewUrl(objectUrl);
            setSelectedFile(file);
        },
        [revokePreview]
    );
 
    useEffect(() => {
        if (!open) {
            revokePreview();
            setError(null);
            setSaving(false);
        }
    }, [open, revokePreview]);
 
    const handleDrop = (e) => {
        e.preventDefault();
        setIsDragOver(false);
        loadPreview(e.dataTransfer.files[0]);
    };
 
    const handleSave = async () => {
        if (!editorRef.current || !selectedFile) return;
        setSaving(true);
        setError(null);
        try {
            const formData = new FormData();
            formData.append("file", selectedFile, selectedFile.name);
            const uploaded = await uploadQuestionImage(formData);
 
            const imageUrl = uploaded?.url;
            if (!imageUrl) {
                // A 2xx with no url is not success. Without this the editor would
                // insert <img src="undefined"> and the teacher would see a broken
                // image with no error to explain it.
                throw new Error("Upload succeeded but returned no image URL");
            }
 
            const imageData = {
                id: uploaded.id,
                filename: uploaded.filename || selectedFile.name,
                image_url: imageUrl,
                size: uploaded.size ?? selectedFile.size,
                mime_type: uploaded.content_type || selectedFile.type,
                storage_key: uploaded.path || null,
            };
            if (onImageUpload) {
                onImageUpload(imageData);
            }
            if (replaceNode) {
                editorRef.current.dom.setAttrib(replaceNode, "src", imageUrl);
                replaceNode.removeAttribute("data-temp-image");
            } else {
                const alt = (selectedFile.name || "image").replace(/"/g, "");
                const safeUrl = escapeHtmlAttr(imageUrl);
                const safeAlt = escapeHtmlAttr(alt);
                editorRef.current.insertContent(
                    `<p><img src="${safeUrl}" alt="${safeAlt}" style="${IMG_TAG_STYLE}" /></p>`
                );
            }
            editorRef.current.dispatch("Change");
            handleClose();
        } catch (e) {
            const msg = e?.message || "Failed to upload image";
            setError(msg);
            console.error("Image insert failed:", e);
        } finally {
            setSaving(false);
        }
    };
 
    return (
        <Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
            <DialogTitle>{replaceNode ? "Change Image" : "Insert Image"}</DialogTitle>
            <DialogContent>
                {error && (
                    <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>
                        {error}
                    </Alert>
                )}
                {previewUrl ? (
                    <Box sx={{ textAlign: "center" }}>
                        <Box
                            component="img"
                            src={previewUrl}
                            alt="preview"
                            sx={{ maxWidth: "100%", maxHeight: 320, objectFit: "contain", borderRadius: 1 }}
                        />
                        <Typography
                            variant="caption"
                            color="primary"
                            sx={{ display: "block", mt: 1, cursor: "pointer" }}
                            onClick={() => fileInputRef.current?.click()}
                        >
                            Change image
                        </Typography>
                    </Box>
                ) : (
                    <Box
                        onDrop={handleDrop}
                        onDragOver={(e) => {
                            e.preventDefault();
                            setIsDragOver(true);
                        }}
                        onDragLeave={() => setIsDragOver(false)}
                        onClick={() => fileInputRef.current?.click()}
                        sx={{
                            border: "2px dashed",
                            borderColor: isDragOver ? "primary.main" : "grey.400",
                            borderRadius: 2,
                            py: 6,
                            textAlign: "center",
                            cursor: "pointer",
                            bgcolor: isDragOver ? "action.hover" : "transparent",
                            transition: "border-color 0.2s, background-color 0.2s",
                        }}
                    >
                        <Typography color="text.secondary">Drop image here</Typography>
                        <Typography variant="caption" color="text.disabled">
                            or click to browse
                        </Typography>
                    </Box>
                )}
                <input
                    ref={fileInputRef}
                    type="file"
                    accept="image/*"
                    style={{ display: "none" }}
                    onChange={(e) => loadPreview(e.target.files[0])}
                />
            </DialogContent>
            <DialogActions>
                <Button id="image-upload-button-cancel" onClick={handleClose} disabled={saving}>
                    Cancel
                </Button>
                <Button
                    id="image-upload-button-save"
                    onClick={() => {
                        void handleSave();
                    }}
                    variant="contained"
                    disabled={!selectedFile || saving}
                >
                    {saving ? "Processing…" : "Save"}
                </Button>
            </DialogActions>
        </Dialog>
    );
};
 
export default ImageUploadDialog;