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, "&").replace(/"/g, """).replace(/</g, "<");
}
/**
* 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;
|