From 48a668295f9c9009e741a6a5c71b4e6ff813d17e Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 29 Jan 2026 12:29:58 +0600 Subject: [PATCH 01/19] feat: add draw image question type to quiz functionality - Introduced a new question type 'draw_image' in the curriculum components. - Updated Question, QuestionConditions, QuestionForm, and QuestionList to support the new question type. - Implemented DrawImage component for handling drawing on images. - Enhanced Quiz class to process answers for draw_image questions. - Added necessary translations and icons for the new question type. --- .../components/curriculum/Question.tsx | 1 + .../curriculum/QuestionConditions.tsx | 4 + .../components/curriculum/QuestionForm.tsx | 2 + .../components/curriculum/QuestionList.tsx | 23 +- .../curriculum/question-types/DrawImage.tsx | 525 ++++++++++++++++++ assets/src/js/v3/shared/utils/types.ts | 1 + classes/Quiz.php | 28 + 7 files changed, 583 insertions(+), 1 deletion(-) create mode 100644 assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx index ab233c1cfc..b2846d5eae 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx @@ -39,6 +39,7 @@ const questionTypeIconMap: Record { matching: , image_answering: , ordering: , + draw_image: , } as const; useEffect(() => { diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx index 1bef032468..c9c7767f3f 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx @@ -103,6 +103,12 @@ const questionTypeOptions: { icon: 'quizOrdering', isPro: true, }, + { + label: __('Draw on Image (R&D)', 'tutor'), + value: 'draw_image', + icon: 'quizImageAnswer', + isPro: true, + }, ]; const isTutorPro = !!tutorConfig.tutor_pro_url; @@ -208,7 +214,22 @@ const QuestionList = ({ isEditing }: { isEditing: boolean }) => { is_correct: '0', }, ] - : [], + : questionType === 'draw_image' + ? [ + { + _data_status: QuizDataStatus.NEW, + is_saved: true, + answer_id: nanoid(), + answer_title: '', + belongs_question_id: questionId, + belongs_question_type: 'draw_image', + answer_two_gap_match: '', + answer_view_format: 'draw_image', + answer_order: 0, + is_correct: '1', + }, + ] + : [], answer_explanation: '', question_mark: 1, question_order: questionFields.length + 1, diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx new file mode 100644 index 0000000000..e7aa9e1a3a --- /dev/null +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx @@ -0,0 +1,525 @@ +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import { useEffect, useRef, useState } from 'react'; +import { useFormContext, useWatch } from 'react-hook-form'; + +import Button from '@TutorShared/atoms/Button'; +import ImageInput from '@TutorShared/atoms/ImageInput'; +import SVGIcon from '@TutorShared/atoms/SVGIcon'; + +import { borderRadius, Breakpoint, colorTokens, spacing } from '@TutorShared/config/styles'; +import { typography } from '@TutorShared/config/typography'; +import Show from '@TutorShared/controls/Show'; +import useWPMedia from '@TutorShared/hooks/useWpMedia'; +import { calculateQuizDataStatus } from '@TutorShared/utils/quiz'; +import { styleUtils } from '@TutorShared/utils/style-utils'; +import { QuizDataStatus, type QuizQuestionOption } from '@TutorShared/utils/types'; +import { nanoid } from '@TutorShared/utils/util'; + +import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; +import { type QuizForm } from '@CourseBuilderServices/quiz'; + +const DrawImage = () => { + const form = useFormContext(); + const { activeQuestionIndex, activeQuestionId } = useQuizModalContext(); + + const answersPath = `questions.${activeQuestionIndex}.question_answers` as 'questions.0.question_answers'; + + const answers = useWatch({ + control: form.control, + name: answersPath, + defaultValue: [] as QuizQuestionOption[], + }) as QuizQuestionOption[]; + + const [brushSize, setBrushSize] = useState(15); + const [isDrawing, setIsDrawing] = useState(false); + + const imageRef = useRef(null); + const canvasRef = useRef(null); + const lastPointRef = useRef<{ x: number; y: number } | null>(null); + + // Ensure there is always a single option for this question type. + useEffect(() => { + if (!activeQuestionId) { + return; + } + + if (!answers || answers.length === 0) { + const baseAnswer: QuizQuestionOption = { + _data_status: QuizDataStatus.NEW, + // Mark as saved so core validation doesn't block on this synthetic option. + is_saved: true, + answer_id: nanoid(), + belongs_question_id: activeQuestionId, + belongs_question_type: 'draw_image' as QuizQuestionOption['belongs_question_type'], + answer_title: '', + is_correct: '1', + image_id: undefined, + image_url: '', + answer_two_gap_match: '', + answer_view_format: 'draw_image', + answer_order: 0, + }; + + form.setValue(answersPath, [baseAnswer]); + } + }, [activeQuestionId, answers, answersPath, form]); + + const option = (answers && answers[0]) as QuizQuestionOption | undefined; + + const { openMediaLibrary, resetFiles } = useWPMedia({ + options: { + type: 'image', + }, + onChange: (file) => { + if (!option) { + return; + } + + if (file && !Array.isArray(file)) { + const { id, url } = file; + const updated: QuizQuestionOption = { + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + image_id: id, + image_url: url, + }; + + form.setValue(answersPath, [updated]); + } + }, + initialFiles: + option && option.image_id + ? { + id: Number(option.image_id), + url: option.image_url || '', + title: option.image_url || '', + } + : null, + }); + + const syncCanvasWithImage = () => { + const img = imageRef.current; + const canvas = canvasRef.current; + + if (!img || !canvas) { + return; + } + + if (!img.complete) { + return; + } + + const imgWidth = img.clientWidth || img.offsetWidth || img.naturalWidth; + const imgHeight = img.clientHeight || img.offsetHeight || img.naturalHeight; + + if (!imgWidth || !imgHeight) { + return; + } + + canvas.width = imgWidth; + canvas.height = imgHeight; + + canvas.style.width = `${imgWidth}px`; + canvas.style.height = `${imgHeight}px`; + + const parentRect = img.parentElement?.getBoundingClientRect(); + const imgRect = img.getBoundingClientRect(); + + if (parentRect) { + const left = imgRect.left - parentRect.left; + const top = imgRect.top - parentRect.top; + canvas.style.position = 'absolute'; + canvas.style.left = `${left}px`; + canvas.style.top = `${top}px`; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; + ctx.fillStyle = 'rgba(255, 0, 0, 0.9)'; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.lineWidth = brushSize; + }; + + useEffect(() => { + syncCanvasWithImage(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [option?.image_url, brushSize]); + + useEffect(() => { + const img = imageRef.current; + if (!img) { + return; + } + + const handleLoad = () => { + syncCanvasWithImage(); + }; + + img.addEventListener('load', handleLoad); + + return () => { + img.removeEventListener('load', handleLoad); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const getCoords = (event: MouseEvent | TouchEvent) => { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + let clientX: number; + let clientY: number; + + if (event instanceof TouchEvent) { + const touch = event.touches[0] || event.changedTouches[0]; + clientX = touch.clientX; + clientY = touch.clientY; + } else { + clientX = event.clientX; + clientY = event.clientY; + } + + return { + x: (clientX - rect.left) * scaleX, + y: (clientY - rect.top) * scaleY, + }; + }; + + const handlePointerDown = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + if (!option?.image_url) { + return; + } + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const coords = getCoords(event); + if (!coords) return; + + setIsDrawing(true); + lastPointRef.current = coords; + + ctx.beginPath(); + ctx.arc(coords.x, coords.y, brushSize / 2, 0, Math.PI * 2); + ctx.fill(); + }; + + const handlePointerMove = (event: MouseEvent | TouchEvent) => { + if (!isDrawing) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const coords = getCoords(event); + const lastPoint = lastPointRef.current; + if (!coords || !lastPoint) return; + + ctx.beginPath(); + ctx.moveTo(lastPoint.x, lastPoint.y); + ctx.lineTo(coords.x, coords.y); + ctx.stroke(); + + lastPointRef.current = coords; + }; + + const handlePointerUp = (event: MouseEvent | TouchEvent) => { + event.preventDefault(); + setIsDrawing(false); + lastPointRef.current = null; + }; + + const handleMouseDown = (event: MouseEvent) => handlePointerDown(event); + const handleMouseMove = (event: MouseEvent) => handlePointerMove(event); + const handleMouseUp = (event: MouseEvent) => handlePointerUp(event); + + const handleTouchStart = (event: TouchEvent) => handlePointerDown(event); + const handleTouchMove = (event: TouchEvent) => handlePointerMove(event); + const handleTouchEnd = (event: TouchEvent) => handlePointerUp(event); + + canvas.addEventListener('mousedown', handleMouseDown); + canvas.addEventListener('mousemove', handleMouseMove); + canvas.addEventListener('mouseup', handleMouseUp); + canvas.addEventListener('mouseleave', handleMouseUp); + + canvas.addEventListener('touchstart', handleTouchStart, { passive: false }); + canvas.addEventListener('touchmove', handleTouchMove, { passive: false }); + canvas.addEventListener('touchend', handleTouchEnd, { passive: false }); + + return () => { + canvas.removeEventListener('mousedown', handleMouseDown); + canvas.removeEventListener('mousemove', handleMouseMove); + canvas.removeEventListener('mouseup', handleMouseUp); + canvas.removeEventListener('mouseleave', handleMouseUp); + + canvas.removeEventListener('touchstart', handleTouchStart); + canvas.removeEventListener('touchmove', handleTouchMove); + canvas.removeEventListener('touchend', handleTouchEnd); + }; + }, [brushSize, isDrawing, option?.image_url]); + + const handleClearDrawing = () => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (!option) { + return; + } + + const updated: QuizQuestionOption = { + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + answer_two_gap_match: '', + is_saved: true, + }; + + form.setValue(answersPath, [updated]); + }; + + const handleSaveZone = () => { + const canvas = canvasRef.current; + if (!canvas || !option) { + return; + } + + const dataUrl = canvas.toDataURL('image/png'); + + const updated: QuizQuestionOption = { + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + // Store instructor's mask in the same field used by the prototype. + answer_two_gap_match: dataUrl, + is_saved: true, + }; + + form.setValue(answersPath, [updated]); + }; + + const clearImage = () => { + if (!option) return; + + const updated: QuizQuestionOption = { + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + image_id: undefined, + image_url: '', + }; + + form.setValue(answersPath, [updated]); + resetFiles(); + + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx?.clearRect(0, 0, canvas.width, canvas.height); + } + }; + + return ( +
+
+
+ +
+ +
+ +
+ setBrushSize(Number(event.target.value))} + /> + {brushSize}px +
+
+
+ + +
+
+ {__('Background + +
+
+ +
+ + +
+ + +

+ {__( + 'An answer zone mask has been saved for this question. Students will be graded against this mask.', + 'tutor', + )} +

+
+
+ + +

+ {__( + 'Upload an image to define the area students must draw on. Then paint the correct zone on top of the image.', + 'tutor', + )} +

+
+
+ ); +}; + +export default DrawImage; + +const styles = { + wrapper: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[16]}; + padding-left: ${spacing[40]}; + + ${Breakpoint.smallMobile} { + padding-left: ${spacing[8]}; + } + `, + controlsRow: css` + ${styleUtils.display.flex('row')}; + flex-wrap: wrap; + gap: ${spacing[16]}; + align-items: flex-end; + `, + imageInputWrapper: css` + max-width: 320px; + `, + imageInput: css` + border-radius: ${borderRadius.card}; + `, + brushControls: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[8]}; + min-width: 220px; + + label { + ${typography.caption('medium')}; + color: ${colorTokens.text.subdued}; + } + `, + brushRangeWrapper: css` + ${styleUtils.display.flex('row')}; + align-items: center; + gap: ${spacing[8]}; + + input[type='range'] { + flex: 1; + } + `, + brushSizeValue: css` + ${typography.caption('medium')}; + color: ${colorTokens.text.subdued}; + min-width: 48px; + text-align: right; + `, + canvasContainer: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[8]}; + `, + canvasInner: css` + position: relative; + display: inline-block; + border-radius: ${borderRadius.card}; + overflow: hidden; + + img { + display: block; + max-width: 100%; + height: auto; + } + `, + image: css` + display: block; + max-width: 100%; + height: auto; + `, + canvas: css` + top: 0; + left: 0; + pointer-events: auto; + `, + actionsRow: css` + ${styleUtils.display.flex('row')}; + gap: ${spacing[12]}; + `, + hint: css` + ${typography.caption()}; + color: ${colorTokens.text.subdued}; + `, + placeholder: css` + ${typography.caption()}; + color: ${colorTokens.text.subdued}; + `, +}; diff --git a/assets/src/js/v3/shared/utils/types.ts b/assets/src/js/v3/shared/utils/types.ts index 95eff58010..aa117bdad6 100644 --- a/assets/src/js/v3/shared/utils/types.ts +++ b/assets/src/js/v3/shared/utils/types.ts @@ -295,6 +295,7 @@ export type QuizQuestionType = | 'image_matching' | 'image_answering' | 'ordering' + | 'draw_image' | 'h5p'; export interface QuizQuestionOption { diff --git a/classes/Quiz.php b/classes/Quiz.php index 790e89b17d..68e5a8ede0 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -736,6 +736,24 @@ function ( $ans ) { // $is_answer_was_correct = ( strtolower( maybe_serialize( array_values( $image_inputs ) ) ) == strtolower( maybe_serialize( $db_answer ) ) ); // } //phpcs:enable + } elseif ( 'draw_image' === $question_type ) { + // Student-submitted mask for draw-on-image question type. + // $answers structure originates from the quiz attempt POST: + // attempt[attempt_id][quiz_question][question_id][answers][mask]. + $given_answer = ''; + + if ( is_array( $answers ) ) { + // Backward-compat / direct structure: ['mask' => 'data:image/png;base64,...']. + if ( isset( $answers['mask'] ) ) { + $given_answer = sanitize_textarea_field( wp_unslash( $answers['mask'] ) ); + } elseif ( isset( $answers['answers']['mask'] ) ) { + // Nested structure from the current template: ['answers' => ['mask' => ...]]. + $given_answer = sanitize_textarea_field( wp_unslash( $answers['answers']['mask'] ) ); + } + } + + // Base correctness is determined later via filters (e.g., in Tutor Pro). + $is_answer_was_correct = false; } $question_mark = $is_answer_was_correct ? $question->question_mark : 0; @@ -766,6 +784,16 @@ function ( $ans ) { $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id ); + // For Pro-powered draw-image questions, adjust total marks after + // add-ons have had a chance to modify achieved_mark via filters. + if ( 'draw_image' === $question_type ) { + // Remove the previously added base question_mark (typically 0 + // for draw_image in core) and add the final achieved_mark + // decided by Pro (or other filters). + $total_marks -= $question_mark; + $total_marks += (float) $answers_data['achieved_mark']; + } + $wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data ); } } From 263cb7967753ff6a3f037c7e4f19201c24ab7c0c Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 2 Feb 2026 13:44:42 +0600 Subject: [PATCH 02/19] feat: enhance draw image question type functionality - Added support for 'draw_image' question type in the quiz, allowing users to draw on images. - Updated JavaScript logic to handle answer validation and reveal mode for draw_image questions. - Enhanced PHP classes to save and process the drawn image mask. - Improved UI to display student submissions and instructor references for draw_image questions. - Added necessary translations and updated quiz attempt details to reflect new functionality. --- assets/src/js/front/course/_spotlight-quiz.js | 24 +- .../curriculum/question-types/DrawImage.tsx | 328 +++++++++++------- classes/Quiz.php | 4 + classes/QuizBuilder.php | 7 +- classes/Utils.php | 59 ++++ views/quiz/attempt-details.php | 67 ++++ 6 files changed, 358 insertions(+), 131 deletions(-) diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index f3d17bcf83..2c3724d65f 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -2,7 +2,7 @@ window.jQuery(document).ready($ => { const { __ } = window.wp.i18n; // Currently only these types of question supports answer reveal mode. - const revealModeSupportedQuestions = ['true_false', 'single_choice', 'multiple_choice']; + const revealModeSupportedQuestions = ['true_false', 'single_choice', 'multiple_choice', 'draw_image']; let quiz_options = _tutorobject.quiz_options let interactions = new Map(); @@ -102,6 +102,13 @@ window.jQuery(document).ready($ => { }); } + // Reveal mode for draw_image: show reference (instructor mask) and explanation. + if (is_reveal_mode() && $question_wrap.data('question-type') === 'draw_image') { + $question_wrap.find('.tutor-quiz-explanation-wrapper').removeClass('tutor-d-none'); + $question_wrap.find('.tutor-draw-image-reference-wrapper').removeClass('tutor-d-none'); + goNext = true; + } + if (validatedTrue) { goNext = true; } @@ -160,7 +167,14 @@ window.jQuery(document).ready($ => { var $inputs = $required_answer_wrap.find('input'); if ($inputs.length) { var $type = $inputs.attr('type'); - if ($type === 'radio') { + // Draw image: require mask (hidden input with [answers][mask]) to have a value. + if ($question_wrap.data('question-type') === 'draw_image') { + var $maskInput = $required_answer_wrap.find('input[name*="[answers][mask]"]'); + if ($maskInput.length && !$maskInput.val().trim().length) { + $question_wrap.find('.answer-help-block').html(`

${__('Please draw on the image to answer this question.', 'tutor')}

`); + validated = false; + } + } else if ($type === 'radio') { if ($required_answer_wrap.find('input[type="radio"]:checked').length == 0) { $question_wrap.find('.answer-help-block').html(`

${__('Please select an option to answer', 'tutor')}

`); validated = false; @@ -219,6 +233,12 @@ window.jQuery(document).ready($ => { } }); + $(document).on('change', '.quiz-attempt-single-question input[name*="[answers][mask]"]', function () { + if ($('.tutor-quiz-time-expired').length === 0 && $(this).val().trim().length) { + $('.tutor-quiz-next-btn-all').prop('disabled', false); + } + }); + $(document).on('click', '.tutor-quiz-answer-next-btn, .tutor-quiz-answer-previous-btn', function (e) { e.preventDefault(); diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx index e7aa9e1a3a..70a26c7a72 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/react'; import { __ } from '@wordpress/i18n'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useFormContext, useWatch } from 'react-hook-form'; import Button from '@TutorShared/atoms/Button'; @@ -19,6 +19,8 @@ import { nanoid } from '@TutorShared/utils/util'; import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; import { type QuizForm } from '@CourseBuilderServices/quiz'; +const BRUSH_SIZE = 15; + const DrawImage = () => { const form = useFormContext(); const { activeQuestionIndex, activeQuestionId } = useQuizModalContext(); @@ -31,8 +33,8 @@ const DrawImage = () => { defaultValue: [] as QuizQuestionOption[], }) as QuizQuestionOption[]; - const [brushSize, setBrushSize] = useState(15); const [isDrawing, setIsDrawing] = useState(false); + const [isDrawModeActive, setIsDrawModeActive] = useState(false); const imageRef = useRef(null); const canvasRef = useRef(null); @@ -100,7 +102,7 @@ const DrawImage = () => { : null, }); - const syncCanvasWithImage = () => { + const syncCanvasWithImage = useCallback((maskUrl?: string) => { const img = imageRef.current; const canvas = canvasRef.current; @@ -112,30 +114,27 @@ const DrawImage = () => { return; } - const imgWidth = img.clientWidth || img.offsetWidth || img.naturalWidth; - const imgHeight = img.clientHeight || img.offsetHeight || img.naturalHeight; - - if (!imgWidth || !imgHeight) { + const container = img.parentElement; + if (!container) { return; } - canvas.width = imgWidth; - canvas.height = imgHeight; - - canvas.style.width = `${imgWidth}px`; - canvas.style.height = `${imgHeight}px`; - - const parentRect = img.parentElement?.getBoundingClientRect(); - const imgRect = img.getBoundingClientRect(); + const rect = container.getBoundingClientRect(); + const w = Math.round(rect.width); + const h = Math.round(rect.height); - if (parentRect) { - const left = imgRect.left - parentRect.left; - const top = imgRect.top - parentRect.top; - canvas.style.position = 'absolute'; - canvas.style.left = `${left}px`; - canvas.style.top = `${top}px`; + if (!w || !h) { + return; } + canvas.width = w; + canvas.height = h; + canvas.style.position = 'absolute'; + canvas.style.top = '0'; + canvas.style.left = '0'; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + const ctx = canvas.getContext('2d'); if (!ctx) { return; @@ -146,13 +145,20 @@ const DrawImage = () => { ctx.fillStyle = 'rgba(255, 0, 0, 0.9)'; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; - ctx.lineWidth = brushSize; - }; + ctx.lineWidth = BRUSH_SIZE; + + if (maskUrl) { + const maskImg = new Image(); + maskImg.onload = () => { + ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height); + }; + maskImg.src = maskUrl; + } + }, []); useEffect(() => { - syncCanvasWithImage(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [option?.image_url, brushSize]); + syncCanvasWithImage(option?.answer_two_gap_match || undefined); + }, [option?.image_url, option?.answer_two_gap_match, syncCanvasWithImage]); useEffect(() => { const img = imageRef.current; @@ -161,7 +167,7 @@ const DrawImage = () => { } const handleLoad = () => { - syncCanvasWithImage(); + syncCanvasWithImage(option?.answer_two_gap_match || undefined); }; img.addEventListener('load', handleLoad); @@ -169,8 +175,30 @@ const DrawImage = () => { return () => { img.removeEventListener('load', handleLoad); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [option?.answer_two_gap_match, syncCanvasWithImage]); + + useEffect(() => { + const img = imageRef.current; + const canvas = canvasRef.current; + if (!img || !canvas) { + return; + } + + const container = img.parentElement; + if (!container) { + return; + } + + const resizeObserver = new ResizeObserver(() => { + syncCanvasWithImage(option?.answer_two_gap_match || undefined); + }); + + resizeObserver.observe(container); + + return () => { + resizeObserver.disconnect(); + }; + }, [option?.image_url, option?.answer_two_gap_match, syncCanvasWithImage]); useEffect(() => { const canvas = canvasRef.current; @@ -202,6 +230,8 @@ const DrawImage = () => { }; const handlePointerDown = (event: MouseEvent | TouchEvent) => { + if (!isDrawModeActive) return; + event.preventDefault(); if (!option?.image_url) { return; @@ -217,7 +247,7 @@ const DrawImage = () => { lastPointRef.current = coords; ctx.beginPath(); - ctx.arc(coords.x, coords.y, brushSize / 2, 0, Math.PI * 2); + ctx.arc(coords.x, coords.y, BRUSH_SIZE / 2, 0, Math.PI * 2); ctx.fill(); }; @@ -272,54 +302,63 @@ const DrawImage = () => { canvas.removeEventListener('touchmove', handleTouchMove); canvas.removeEventListener('touchend', handleTouchEnd); }; - }, [brushSize, isDrawing, option?.image_url]); + }, [isDrawing, isDrawModeActive, option?.image_url]); - const handleClearDrawing = () => { + const handleSave = () => { const canvas = canvasRef.current; - if (!canvas) return; + if (!canvas || !option) return; - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - ctx.clearRect(0, 0, canvas.width, canvas.height); - - if (!option) { - return; - } + const dataUrl = canvas.toDataURL('image/png'); + const blank = document.createElement('canvas'); + blank.width = canvas.width; + blank.height = canvas.height; + const isEmpty = dataUrl === blank.toDataURL(); const updated: QuizQuestionOption = { ...option, ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, }), - answer_two_gap_match: '', + answer_two_gap_match: isEmpty ? '' : dataUrl, is_saved: true, }; - form.setValue(answersPath, [updated]); }; - const handleSaveZone = () => { + const handleClear = () => { const canvas = canvasRef.current; - if (!canvas || !option) { - return; - } + if (!canvas) return; - const dataUrl = canvas.toDataURL('image/png'); + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (!option) return; const updated: QuizQuestionOption = { ...option, ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, }), - // Store instructor's mask in the same field used by the prototype. - answer_two_gap_match: dataUrl, + answer_two_gap_match: '', is_saved: true, }; - form.setValue(answersPath, [updated]); }; + const handleDraw = () => { + setIsDrawModeActive(true); + + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + }; + const clearImage = () => { if (!option) return; @@ -344,7 +383,8 @@ const DrawImage = () => { return (
-
+ {/* Section 1: Image upload only — one reference shown in Mark the correct area */} +
{ previewImageCss={styles.imageInput} />
- -
- -
- setBrushSize(Number(event.target.value))} - /> - {brushSize}px -
-
+ {/* Section 2: Mark the correct area — single reference image + drawing canvas; Save / Clear / Draw buttons */} -
+
+
+ + + {__('Mark the correct area', 'tutor')} + +
{__('Background + -
-
- -
- - -
- - -

- {__( - 'An answer zone mask has been saved for this question. Students will be graded against this mask.', - 'tutor', - )} +

+ + + +
+

+ {__('Use the brush to draw on the image, then click Save to store the answer zone.', 'tutor')}

-
+ +

{__('Answer zone saved. Students will be graded against this area.', 'tutor')}

+
+

{__( - 'Upload an image to define the area students must draw on. Then paint the correct zone on top of the image.', + 'Upload an image to define the area students must draw on. Then mark the correct zone in the next section.', 'tutor', )}

@@ -440,53 +480,67 @@ export default DrawImage; const styles = { wrapper: css` ${styleUtils.display.flex('column')}; - gap: ${spacing[16]}; + gap: ${spacing[24]}; padding-left: ${spacing[40]}; ${Breakpoint.smallMobile} { padding-left: ${spacing[8]}; } `, - controlsRow: css` - ${styleUtils.display.flex('row')}; - flex-wrap: wrap; + card: css` + ${styleUtils.display.flex('column')}; gap: ${spacing[16]}; - align-items: flex-end; + padding: ${spacing[20]}; + background: ${colorTokens.surface.tutor}; + border: 1px solid ${colorTokens.stroke.border}; + border-radius: ${borderRadius.card}; `, imageInputWrapper: css` - max-width: 320px; + max-width: 100%; `, imageInput: css` border-radius: ${borderRadius.card}; `, - brushControls: css` - ${styleUtils.display.flex('column')}; - gap: ${spacing[8]}; - min-width: 220px; - - label { - ${typography.caption('medium')}; - color: ${colorTokens.text.subdued}; - } + answerHeader: css` + ${styleUtils.display.flex('row')}; + align-items: center; + justify-content: space-between; + gap: ${spacing[12]}; `, - brushRangeWrapper: css` + answerHeaderTitle: css` + ${typography.body('medium')}; + color: ${colorTokens.text.primary}; ${styleUtils.display.flex('row')}; align-items: center; gap: ${spacing[8]}; - - input[type='range'] { - flex: 1; - } `, - brushSizeValue: css` - ${typography.caption('medium')}; + headerIcon: css` + flex-shrink: 0; color: ${colorTokens.text.subdued}; - min-width: 48px; - text-align: right; `, - canvasContainer: css` - ${styleUtils.display.flex('column')}; - gap: ${spacing[8]}; + deleteButton: css` + display: inline-flex; + align-items: center; + justify-content: center; + padding: ${spacing[8]}; + background: transparent; + border: none; + border-radius: ${borderRadius.button}; + color: ${colorTokens.text.subdued}; + cursor: pointer; + transition: + color 0.15s ease, + background 0.15s ease; + + &:hover { + color: ${colorTokens.text.primary}; + background: ${colorTokens.background.hover}; + } + + &:focus-visible { + outline: 2px solid ${colorTokens.action.primary.focus}; + outline-offset: 2px; + } `, canvasInner: css` position: relative; @@ -505,18 +559,36 @@ const styles = { max-width: 100%; height: auto; `, + answerImage: css` + filter: grayscale(0.15); + `, canvas: css` + position: absolute; top: 0; left: 0; + `, + canvasIdleMode: css` + pointer-events: none; + cursor: default; + `, + canvasDrawMode: css` pointer-events: auto; + cursor: crosshair; `, actionsRow: css` ${styleUtils.display.flex('row')}; gap: ${spacing[12]}; + flex-wrap: wrap; `, - hint: css` + brushHint: css` ${typography.caption()}; color: ${colorTokens.text.subdued}; + margin: 0; + `, + savedHint: css` + ${typography.caption()}; + color: ${colorTokens.text.success}; + margin: 0; `, placeholder: css` ${typography.caption()}; diff --git a/classes/Quiz.php b/classes/Quiz.php index 68e5a8ede0..2041eb7164 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -751,6 +751,10 @@ function ( $ans ) { $given_answer = sanitize_textarea_field( wp_unslash( $answers['answers']['mask'] ) ); } } + // Save base64 mask to uploads and store file URL in DB. + if ( '' !== $given_answer ) { + $given_answer = tutor_utils()->save_quiz_draw_image_mask( $given_answer ); + } // Base correctness is determined later via filters (e.g., in Tutor Pro). $is_answer_was_correct = false; diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index 5acd03a171..3ff0c8eaea 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -89,7 +89,12 @@ public function prepare_answer_data( $question_id, $question_type, $input ) { $answer_title = Input::sanitize( wp_slash( $input['answer_title'] ) ?? '', '' ); $is_correct = Input::sanitize( $input['is_correct'] ?? 0, 0, Input::TYPE_INT ); $image_id = Input::sanitize( $input['image_id'] ?? null ); - $answer_two_gap_match = Input::sanitize( $input['answer_two_gap_match'] ?? '' ); + // Draw image: pass raw base64 to save_quiz_draw_image_mask (sanitize would corrupt base64); store returned URL. + if ( 'draw_image' === $question_type && isset( $input['answer_two_gap_match'] ) ) { + $answer_two_gap_match = tutor_utils()->save_quiz_draw_image_mask( wp_unslash( $input['answer_two_gap_match'] ) ); + } else { + $answer_two_gap_match = Input::sanitize( $input['answer_two_gap_match'] ?? '', '' ); + } $answer_view_format = Input::sanitize( $input['answer_view_format'] ?? '' ); $answer_settings = null; diff --git a/classes/Utils.php b/classes/Utils.php index 08ac972d96..ae8e2570ea 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -5138,6 +5138,11 @@ public function get_question_types( $type = null ) { 'icon' => '', 'is_pro' => true, ), + 'draw_image' => array( + 'name' => __( 'Draw on Image', 'tutor' ), + 'icon' => '', + 'is_pro' => true, + ), ); if ( isset( $types[ $type ] ) ) { @@ -10579,6 +10584,60 @@ public function get_editor_used( $post_id ) { return apply_filters( 'tutor_course_builder_editor_used', $editor, $post_id ); } + /** + * Save quiz draw-image mask (base64) to uploads and return URL. + * + * Stores the file under wp-content/uploads/tutor/quiz-type/ with a unique + * filename. Used for both instructor reference mask and student attempt mask. + * + * @since 3.8.3 + * + * @param string $base64_or_url base64 image string (data:image/...;base64,...) or existing URL. + * + * @return string URL of the saved file, or original value if not base64 / save failed. + */ + public function save_quiz_draw_image_mask( $base64_or_url ) { + $value = is_string( $base64_or_url ) ? trim( $base64_or_url ) : ''; + if ( '' === $value ) { + return $value; + } + // Already a URL (e.g. previously saved file or legacy). + if ( preg_match( '#^https?://#i', $value ) ) { + return $value; + } + if ( ! preg_match( '#^data:image/(\w+);base64,(.+)$#is', $value, $m ) ) { + return $value; + } + $ext = strtolower( $m[1] ); + $ext = 'jpeg' === $ext ? 'jpg' : $ext; + $decoded = base64_decode( $m[2], true ); + if ( false === $decoded || '' === $decoded ) { + return $value; + } + $upload_dir = wp_upload_dir(); + if ( ! empty( $upload_dir['error'] ) ) { + return $value; + } + $subdir = 'tutor/quiz-type'; + $base_path = $upload_dir['basedir'] . '/' . $subdir; + $base_url = $upload_dir['baseurl'] . '/' . $subdir; + if ( ! wp_mkdir_p( $base_path ) ) { + return $value; + } + // Prevent directory listing. + $index_file = $base_path . '/index.php'; + if ( ! file_exists( $index_file ) ) { + file_put_contents( $index_file, 'question_type, array( 'open_ended', 'short_answer', 'image_answering' ), true ) ) { $answer_status = null === $answer->is_correct ? 'pending' : 'wrong'; } + + // Draw image: auto-graded, so correct or wrong only. + elseif ( 'draw_image' === $answer->question_type ) { + $answer_status = (bool) $answer->is_correct ? 'correct' : 'wrong'; + } ?> @@ -526,6 +531,38 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an } tutor_render_answer_list( $answers ); + } elseif ( 'draw_image' === $answer->question_type ) { + + // Student's submitted drawing: show background image with their mask overlaid (same as attempt). + // Mask can be stored as file URL (wp-content/uploads/tutor/quiz-type/) or legacy base64. + $given_mask = ! empty( $answer->given_answer ) ? stripslashes( $answer->given_answer ) : ''; + $has_mask = '' !== $given_mask; + + if ( $has_mask ) { + $draw_image_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, false ); + $instructor_answer = is_array( $draw_image_answers ) && ! empty( $draw_image_answers ) ? reset( $draw_image_answers ) : null; + $given_bg_url = ''; + if ( $instructor_answer ) { + if ( ! empty( $instructor_answer->image_id ) ) { + $given_bg_url = wp_get_attachment_image_url( $instructor_answer->image_id, 'full' ); + } elseif ( ! empty( $instructor_answer->image_url ) ) { + $given_bg_url = $instructor_answer->image_url; + } + } + echo '
'; + echo '

' . esc_html__( 'Your drawing:', 'tutor' ) . '

'; + echo '
'; + if ( $given_bg_url ) { + echo ''; + echo ''; + } else { + echo ''; + } + echo '
'; + echo '
'; + } else { + echo '' . esc_html__( 'No drawing submitted.', 'tutor' ) . ''; + } } ?>
@@ -695,6 +732,36 @@ function( $ans ) { } echo '
'; } + + // Draw image: show instructor reference (correct answer zones). + elseif ( 'draw_image' === $answer->question_type ) { + + $draw_image_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, false ); + $instructor_answer = is_array( $draw_image_answers ) && ! empty( $draw_image_answers ) ? reset( $draw_image_answers ) : null; + + if ( $instructor_answer && ! empty( $instructor_answer->answer_two_gap_match ) ) { + $ref_mask = $instructor_answer->answer_two_gap_match; + $ref_bg = ''; + if ( ! empty( $instructor_answer->image_id ) ) { + $ref_bg = wp_get_attachment_image_url( $instructor_answer->image_id, 'full' ); + } elseif ( ! empty( $instructor_answer->image_url ) ) { + $ref_bg = $instructor_answer->image_url; + } + echo '
'; + echo '

' . esc_html__( 'Reference (correct answer zones):', 'tutor' ) . '

'; + if ( $ref_bg ) { + echo '
'; + echo ''; + echo ''; + echo '
'; + } else { + echo ''; + } + echo '
'; + } else { + echo '' . esc_html__( 'No reference available.', 'tutor' ) . ''; + } + } } ?> From cf81225b32e676e9713282b08622b6692be54e22 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 2 Feb 2026 13:54:12 +0600 Subject: [PATCH 03/19] fix: update label for draw image question type - Removed "(R&D)" from the label of the 'draw_image' question type in both QuestionConditions and QuestionList components for clarity and consistency. - Ensured translations are updated accordingly in the 'tutor' text domain. --- .../course-builder/components/curriculum/QuestionConditions.tsx | 2 +- .../course-builder/components/curriculum/QuestionList.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx index 38d6e58bb6..d7745caf2c 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionConditions.tsx @@ -51,7 +51,7 @@ const questionTypes = { icon: 'quizOrdering', }, draw_image: { - label: __('Draw on Image (R&D)', 'tutor'), + label: __('Draw on Image', 'tutor'), icon: 'quizImageAnswer', }, h5p: { diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx index c9c7767f3f..09627afe13 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx @@ -104,7 +104,7 @@ const questionTypeOptions: { isPro: true, }, { - label: __('Draw on Image (R&D)', 'tutor'), + label: __('Draw on Image', 'tutor'), value: 'draw_image', icon: 'quizImageAnswer', isPro: true, From 476f118cae8166c6755825bec22c9e51efa2cb9c Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 2 Feb 2026 14:06:01 +0600 Subject: [PATCH 04/19] refactor: simplify answer processing for draw image question type - Streamlined the logic for handling student-submitted masks in the 'draw_image' question type. - Removed redundant code and ensured that the answer is processed correctly from the nested structure. - Improved code readability and maintainability by consolidating answer extraction logic. --- classes/Quiz.php | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 2041eb7164..d01d8d0d91 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -737,19 +737,9 @@ function ( $ans ) { // } //phpcs:enable } elseif ( 'draw_image' === $question_type ) { - // Student-submitted mask for draw-on-image question type. - // $answers structure originates from the quiz attempt POST: - // attempt[attempt_id][quiz_question][question_id][answers][mask]. $given_answer = ''; - - if ( is_array( $answers ) ) { - // Backward-compat / direct structure: ['mask' => 'data:image/png;base64,...']. - if ( isset( $answers['mask'] ) ) { - $given_answer = sanitize_textarea_field( wp_unslash( $answers['mask'] ) ); - } elseif ( isset( $answers['answers']['mask'] ) ) { - // Nested structure from the current template: ['answers' => ['mask' => ...]]. - $given_answer = sanitize_textarea_field( wp_unslash( $answers['answers']['mask'] ) ); - } + if ( is_array( $answers ) && isset( $answers['answers']['mask'] ) ) { + $given_answer = sanitize_textarea_field( wp_unslash( $answers['answers']['mask'] ) ); } // Save base64 mask to uploads and store file URL in DB. if ( '' !== $given_answer ) { From 3ecb33cd96eed763052640ccee5deac4c1067092 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 2 Feb 2026 15:24:32 +0600 Subject: [PATCH 05/19] refactor: improve handling of draw image answer processing - Updated comments for clarity on handling raw base64 or URL inputs in the 'draw_image' question type. - Ensured that the URL returned from saving the drawn image mask is properly sanitized using esc_url_raw. - Enhanced code readability by clarifying the sanitization process for different input types. --- classes/QuizBuilder.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index 3ff0c8eaea..b2753ed9e8 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -89,9 +89,12 @@ public function prepare_answer_data( $question_id, $question_type, $input ) { $answer_title = Input::sanitize( wp_slash( $input['answer_title'] ) ?? '', '' ); $is_correct = Input::sanitize( $input['is_correct'] ?? 0, 0, Input::TYPE_INT ); $image_id = Input::sanitize( $input['image_id'] ?? null ); - // Draw image: pass raw base64 to save_quiz_draw_image_mask (sanitize would corrupt base64); store returned URL. + // Draw image: pass raw base64 or URL to save_quiz_draw_image_mask (Input::sanitize would corrupt base64 + // and sanitize_text_field can strip URL chars); it returns a URL—sanitize that with esc_url_raw. if ( 'draw_image' === $question_type && isset( $input['answer_two_gap_match'] ) ) { - $answer_two_gap_match = tutor_utils()->save_quiz_draw_image_mask( wp_unslash( $input['answer_two_gap_match'] ) ); + $answer_two_gap_match = esc_url_raw( + tutor_utils()->save_quiz_draw_image_mask( wp_unslash( $input['answer_two_gap_match'] ) ) + ); } else { $answer_two_gap_match = Input::sanitize( $input['answer_two_gap_match'] ?? '', '' ); } From 2a4c255f414d5a13d28aa80f1fbbba06b11d5a94 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 2 Feb 2026 15:53:22 +0600 Subject: [PATCH 06/19] refactor: enhance draw image answer processing and security - Improved handling of base64 and URL inputs for the 'draw_image' question type. - Added security checks to ensure only local uploads are accepted, rejecting external URLs. - Clarified comments and improved code readability regarding image saving and processing. - Ensured that the saved image is always stored with a .png extension for consistency. --- classes/Utils.php | 44 +++++++++++++++++++++++++++++++------------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/classes/Utils.php b/classes/Utils.php index ae8e2570ea..ae08378fba 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -10601,41 +10601,59 @@ public function save_quiz_draw_image_mask( $base64_or_url ) { if ( '' === $value ) { return $value; } - // Already a URL (e.g. previously saved file or legacy). - if ( preg_match( '#^https?://#i', $value ) ) { + + $upload_dir = wp_upload_dir(); + if ( ! empty( $upload_dir['error'] ) ) { return $value; } + + $subdir = 'tutor/quiz-type'; + $base_path = trailingslashit( $upload_dir['basedir'] ) . $subdir; + $base_url = trailingslashit( $upload_dir['baseurl'] ) . $subdir; + + // Already a URL (e.g. previously saved file or legacy) – only allow local uploads URLs. + if ( preg_match( '#^https?://#i', $value ) ) { + $uploads_base = trailingslashit( $upload_dir['baseurl'] ); + + // Only trust URLs that point to the site's uploads directory. + if ( 0 === strpos( $value, $uploads_base ) ) { + return $value; + } + + // Reject external/unknown URLs for security – data should live on instructor server only. + return ''; + } + + // Expect a data:image/*;base64,... URI from the canvas. if ( ! preg_match( '#^data:image/(\w+);base64,(.+)$#is', $value, $m ) ) { return $value; } - $ext = strtolower( $m[1] ); - $ext = 'jpeg' === $ext ? 'jpg' : $ext; + + // Decode image data. Frontend uses PNG; we always persist as .png on disk. $decoded = base64_decode( $m[2], true ); if ( false === $decoded || '' === $decoded ) { return $value; } - $upload_dir = wp_upload_dir(); - if ( ! empty( $upload_dir['error'] ) ) { - return $value; - } - $subdir = 'tutor/quiz-type'; - $base_path = $upload_dir['basedir'] . '/' . $subdir; - $base_url = $upload_dir['baseurl'] . '/' . $subdir; + if ( ! wp_mkdir_p( $base_path ) ) { return $value; } + // Prevent directory listing. $index_file = $base_path . '/index.php'; if ( ! file_exists( $index_file ) ) { file_put_contents( $index_file, ' Date: Mon, 2 Feb 2026 16:05:03 +0600 Subject: [PATCH 07/19] refactor: clarify handling of local file URLs for draw image question type - Updated comments to specify that masks are stored as local file URLs only. - Improved condition checks to ensure only valid local URLs are processed for student and instructor submitted drawings. - Enhanced code readability by removing redundant checks and ensuring consistent use of esc_url for outputting image URLs. --- views/quiz/attempt-details.php | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php index abcef8f372..23111cd3b6 100644 --- a/views/quiz/attempt-details.php +++ b/views/quiz/attempt-details.php @@ -533,12 +533,11 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an tutor_render_answer_list( $answers ); } elseif ( 'draw_image' === $answer->question_type ) { - // Student's submitted drawing: show background image with their mask overlaid (same as attempt). - // Mask can be stored as file URL (wp-content/uploads/tutor/quiz-type/) or legacy base64. - $given_mask = ! empty( $answer->given_answer ) ? stripslashes( $answer->given_answer ) : ''; - $has_mask = '' !== $given_mask; + // Student's submitted drawing: mask is stored as local file URL only + $given_mask = ! empty( $answer->given_answer ) ? stripslashes( $answer->given_answer ) : ''; + $given_mask_is_url = is_string( $given_mask ) && preg_match( '#^https?://#i', $given_mask ); - if ( $has_mask ) { + if ( $given_mask_is_url ) { $draw_image_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, false ); $instructor_answer = is_array( $draw_image_answers ) && ! empty( $draw_image_answers ) ? reset( $draw_image_answers ) : null; $given_bg_url = ''; @@ -554,9 +553,9 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an echo '
'; if ( $given_bg_url ) { echo ''; - echo ''; + echo ''; } else { - echo ''; + echo ''; } echo '
'; echo ''; @@ -733,15 +732,16 @@ function( $ans ) { echo ''; } - // Draw image: show instructor reference (correct answer zones). + // Draw image: show instructor reference (correct answer zones). Mask is local file URL only. elseif ( 'draw_image' === $answer->question_type ) { $draw_image_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, false ); $instructor_answer = is_array( $draw_image_answers ) && ! empty( $draw_image_answers ) ? reset( $draw_image_answers ) : null; + $ref_mask = $instructor_answer && ! empty( $instructor_answer->answer_two_gap_match ) ? $instructor_answer->answer_two_gap_match : ''; + $ref_mask_is_url = is_string( $ref_mask ) && preg_match( '#^https?://#i', $ref_mask ); - if ( $instructor_answer && ! empty( $instructor_answer->answer_two_gap_match ) ) { - $ref_mask = $instructor_answer->answer_two_gap_match; - $ref_bg = ''; + if ( $instructor_answer && $ref_mask_is_url ) { + $ref_bg = ''; if ( ! empty( $instructor_answer->image_id ) ) { $ref_bg = wp_get_attachment_image_url( $instructor_answer->image_id, 'full' ); } elseif ( ! empty( $instructor_answer->image_url ) ) { @@ -752,10 +752,10 @@ function( $ans ) { if ( $ref_bg ) { echo '
'; echo ''; - echo ''; + echo ''; echo '
'; } else { - echo ''; + echo ''; } echo ''; } else { From a16204d523cbdda02313760ba307d49e39a93444 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 3 Feb 2026 12:51:29 +0600 Subject: [PATCH 08/19] refactor: migrate draw image mask handling to QuizModel - Refactored the draw image mask functionality by moving the saving logic from Utils to QuizModel for better organization. - Updated references in Quiz and QuizBuilder classes to utilize the new QuizModel method. - Improved code clarity by enhancing comments and ensuring consistent handling of base64 and URL inputs. - Removed deprecated save_quiz_draw_image_mask method from Utils to streamline the codebase. --- classes/Quiz.php | 6 +-- classes/QuizBuilder.php | 4 +- classes/Utils.php | 72 ------------------------------------ models/QuizModel.php | 81 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 77 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index d01d8d0d91..3badbc3ef9 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -739,14 +739,14 @@ function ( $ans ) { } elseif ( 'draw_image' === $question_type ) { $given_answer = ''; if ( is_array( $answers ) && isset( $answers['answers']['mask'] ) ) { - $given_answer = sanitize_textarea_field( wp_unslash( $answers['answers']['mask'] ) ); + $given_answer = Input::sanitize( $answers['answers']['mask'] ?? '', '' ); } // Save base64 mask to uploads and store file URL in DB. if ( '' !== $given_answer ) { - $given_answer = tutor_utils()->save_quiz_draw_image_mask( $given_answer ); + $given_answer = QuizModel::save_quiz_draw_image_mask( $given_answer ); } - // Base correctness is determined later via filters (e.g., in Tutor Pro). + // Base correctness is determined later via filters in Tutor Pro. $is_answer_was_correct = false; } diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index b2753ed9e8..4272f5bbc7 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -89,11 +89,11 @@ public function prepare_answer_data( $question_id, $question_type, $input ) { $answer_title = Input::sanitize( wp_slash( $input['answer_title'] ) ?? '', '' ); $is_correct = Input::sanitize( $input['is_correct'] ?? 0, 0, Input::TYPE_INT ); $image_id = Input::sanitize( $input['image_id'] ?? null ); - // Draw image: pass raw base64 or URL to save_quiz_draw_image_mask (Input::sanitize would corrupt base64 + // Draw image: pass raw base64 or URL to QuizModel::save_quiz_draw_image_mask (Input::sanitize would corrupt base64 // and sanitize_text_field can strip URL chars); it returns a URL—sanitize that with esc_url_raw. if ( 'draw_image' === $question_type && isset( $input['answer_two_gap_match'] ) ) { $answer_two_gap_match = esc_url_raw( - tutor_utils()->save_quiz_draw_image_mask( wp_unslash( $input['answer_two_gap_match'] ) ) + QuizModel::save_quiz_draw_image_mask( wp_unslash( $input['answer_two_gap_match'] ) ) ); } else { $answer_two_gap_match = Input::sanitize( $input['answer_two_gap_match'] ?? '', '' ); diff --git a/classes/Utils.php b/classes/Utils.php index ae08378fba..a405830f4b 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -10584,78 +10584,6 @@ public function get_editor_used( $post_id ) { return apply_filters( 'tutor_course_builder_editor_used', $editor, $post_id ); } - /** - * Save quiz draw-image mask (base64) to uploads and return URL. - * - * Stores the file under wp-content/uploads/tutor/quiz-type/ with a unique - * filename. Used for both instructor reference mask and student attempt mask. - * - * @since 3.8.3 - * - * @param string $base64_or_url base64 image string (data:image/...;base64,...) or existing URL. - * - * @return string URL of the saved file, or original value if not base64 / save failed. - */ - public function save_quiz_draw_image_mask( $base64_or_url ) { - $value = is_string( $base64_or_url ) ? trim( $base64_or_url ) : ''; - if ( '' === $value ) { - return $value; - } - - $upload_dir = wp_upload_dir(); - if ( ! empty( $upload_dir['error'] ) ) { - return $value; - } - - $subdir = 'tutor/quiz-type'; - $base_path = trailingslashit( $upload_dir['basedir'] ) . $subdir; - $base_url = trailingslashit( $upload_dir['baseurl'] ) . $subdir; - - // Already a URL (e.g. previously saved file or legacy) – only allow local uploads URLs. - if ( preg_match( '#^https?://#i', $value ) ) { - $uploads_base = trailingslashit( $upload_dir['baseurl'] ); - - // Only trust URLs that point to the site's uploads directory. - if ( 0 === strpos( $value, $uploads_base ) ) { - return $value; - } - - // Reject external/unknown URLs for security – data should live on instructor server only. - return ''; - } - - // Expect a data:image/*;base64,... URI from the canvas. - if ( ! preg_match( '#^data:image/(\w+);base64,(.+)$#is', $value, $m ) ) { - return $value; - } - - // Decode image data. Frontend uses PNG; we always persist as .png on disk. - $decoded = base64_decode( $m[2], true ); - if ( false === $decoded || '' === $decoded ) { - return $value; - } - - if ( ! wp_mkdir_p( $base_path ) ) { - return $value; - } - - // Prevent directory listing. - $index_file = $base_path . '/index.php'; - if ( ! file_exists( $index_file ) ) { - file_put_contents( $index_file, 'exists( $index_file ) ) { + $wp_filesystem->put_contents( $index_file, 'put_contents( $filepath, $decoded ) ) { + return $value; + } + + return trailingslashit( $base_url ) . $filename; + } } From 90f8986ce6c1d36e001e472c383544c3995de410 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 3 Feb 2026 12:56:17 +0600 Subject: [PATCH 09/19] refactor: improve URL validation for draw image masks - Updated the URL validation logic for student and instructor submitted drawing masks to use wp_http_validate_url for enhanced security. - Ensured consistent handling of URLs across the quiz attempt details view. - Improved code clarity by maintaining a uniform approach to URL checks. --- views/quiz/attempt-details.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php index 23111cd3b6..acbae01a2f 100644 --- a/views/quiz/attempt-details.php +++ b/views/quiz/attempt-details.php @@ -534,8 +534,8 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an } elseif ( 'draw_image' === $answer->question_type ) { // Student's submitted drawing: mask is stored as local file URL only - $given_mask = ! empty( $answer->given_answer ) ? stripslashes( $answer->given_answer ) : ''; - $given_mask_is_url = is_string( $given_mask ) && preg_match( '#^https?://#i', $given_mask ); + $given_mask = ! empty( $answer->given_answer ) ? stripslashes( $answer->given_answer ) : ''; + $given_mask_is_url = is_string( $given_mask ) && false !== wp_http_validate_url( $given_mask ); if ( $given_mask_is_url ) { $draw_image_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, false ); @@ -738,7 +738,7 @@ function( $ans ) { $draw_image_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, false ); $instructor_answer = is_array( $draw_image_answers ) && ! empty( $draw_image_answers ) ? reset( $draw_image_answers ) : null; $ref_mask = $instructor_answer && ! empty( $instructor_answer->answer_two_gap_match ) ? $instructor_answer->answer_two_gap_match : ''; - $ref_mask_is_url = is_string( $ref_mask ) && preg_match( '#^https?://#i', $ref_mask ); + $ref_mask_is_url = is_string( $ref_mask ) && false !== wp_http_validate_url( $ref_mask ); if ( $instructor_answer && $ref_mask_is_url ) { $ref_bg = ''; From fd10003800e909ea0bc4070444470fc3008ab578 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 3 Feb 2026 13:45:55 +0600 Subject: [PATCH 10/19] refactor: enhance upload_base64_image method in Utils - Updated the upload_base64_image method to include an optional parameter for adding the uploaded file to the WordPress media library. - Improved documentation to clarify the behavior of the method when the new parameter is set to false, ensuring it returns an ID of 0. - Streamlined the image upload process in QuizModel by utilizing the updated method for handling base64 images. --- classes/Utils.php | 31 ++++++++++++++----------- models/QuizModel.php | 54 ++++++++++++++------------------------------ 2 files changed, 35 insertions(+), 50 deletions(-) diff --git a/classes/Utils.php b/classes/Utils.php index a405830f4b..0bd72cfb61 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -10590,13 +10590,14 @@ public function get_editor_used( $post_id ) { * @since 3.0.0 * * @param string $base64_image_str base64 image string. - * @param string $filename filename. + * @param string $filename filename. + * @param bool $add_to_media Optional. Whether to add the file to WordPress media library. Default true. * - * @return object consist of id, title, url. + * @return object consist of id, title, url. When $add_to_media is false, id is 0. * * @throws \Exception If upload failed. */ - public function upload_base64_image( $base64_image_str, $filename = null ) { + public function upload_base64_image( $base64_image_str, $filename = null, $add_to_wp_media = true ) { try { $arr = explode( ',', $base64_image_str, 2 ); if ( ! isset( $arr[1] ) ) { @@ -10611,17 +10612,21 @@ public function upload_base64_image( $base64_image_str, $filename = null ) { throw new \Exception( $uploaded['error'] ); } - $attachment = array( - 'guid' => $uploaded['url'], - 'post_mime_type' => $uploaded['type'], - 'post_title' => $filename, - 'post_content' => '', - 'post_status' => 'inherit', - ); + if ( $add_to_wp_media ) { + $attachment = array( + 'guid' => $uploaded['url'], + 'post_mime_type' => $uploaded['type'], + 'post_title' => $filename, + 'post_content' => '', + 'post_status' => 'inherit', + ); - $media_id = wp_insert_attachment( $attachment, $uploaded['file'] ); - $attach_data = wp_generate_attachment_metadata( $media_id, $uploaded['file'] ); - wp_update_attachment_metadata( $media_id, $attach_data ); + $media_id = wp_insert_attachment( $attachment, $uploaded['file'] ); + $attach_data = wp_generate_attachment_metadata( $media_id, $uploaded['file'] ); + wp_update_attachment_metadata( $media_id, $attach_data ); + } else { + $media_id = 0; + } return (object) array( 'id' => $media_id, diff --git a/models/QuizModel.php b/models/QuizModel.php index 14d62b9d20..5b34ff13c7 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -1334,10 +1334,6 @@ public static function save_quiz_draw_image_mask( $base64_or_url ) { return $value; } - $subdir = 'tutor/quiz-type'; - $base_path = trailingslashit( $upload_dir['basedir'] ) . $subdir; - $base_url = trailingslashit( $upload_dir['baseurl'] ) . $subdir; - // Already a URL (e.g. previously saved file or legacy) – only allow local uploads URLs. if ( preg_match( '#^https?://#i', $value ) ) { $uploads_base = trailingslashit( $upload_dir['baseurl'] ); @@ -1351,44 +1347,28 @@ public static function save_quiz_draw_image_mask( $base64_or_url ) { return ''; } - // Expect a data:image/*;base64,... URI from the canvas. + // Expect a data:image/*;base64,... URI from the canvas. Save to tutor/quiz-image (not wp media). if ( ! preg_match( '#^data:image/(\w+);base64,(.+)$#is', $value, $m ) ) { return $value; } - // Decode image data. Frontend uses PNG; we always persist as .png on disk. - $decoded = base64_decode( $m[2], true ); - if ( false === $decoded || '' === $decoded ) { - return $value; - } - - if ( ! wp_mkdir_p( $base_path ) ) { - return $value; - } - - if ( ! function_exists( 'WP_Filesystem' ) ) { - require_once ABSPATH . 'wp-admin/includes/file.php'; - } - WP_Filesystem(); - global $wp_filesystem; - if ( ! is_object( $wp_filesystem ) ) { + $subdir_path = 'tutor/quiz-image'; + $filter_upload_dir = function ( $uploads ) use ( $subdir_path ) { + $uploads['path'] = trailingslashit( $uploads['basedir'] ) . $subdir_path; + $uploads['url'] = trailingslashit( $uploads['baseurl'] ) . $subdir_path; + $uploads['subdir'] = '/' . $subdir_path; + return $uploads; + }; + + add_filter( 'upload_dir', $filter_upload_dir, 10, 1 ); + try { + $basename = 'draw-mask-' . gmdate( 'Y-m-d-His' ) . '-' . wp_rand( 1000, 9999 ) . '.png'; + $result = tutor_utils()->upload_base64_image( $value, $basename, false ); + return $result->url; + } catch ( \Exception $e ) { return $value; + } finally { + remove_filter( 'upload_dir', $filter_upload_dir, 10 ); } - - // Prevent directory listing. - $index_file = $base_path . '/index.php'; - if ( ! $wp_filesystem->exists( $index_file ) ) { - $wp_filesystem->put_contents( $index_file, 'put_contents( $filepath, $decoded ) ) { - return $value; - } - - return trailingslashit( $base_url ) . $filename; } } From 46a3c8654bf2eea1b2f191d773286bec47149cda Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 3 Feb 2026 15:42:11 +0600 Subject: [PATCH 11/19] refactor: enhance draw image answer handling in Quiz and QuizModel - Added a new filter for processing draw image answers in the Quiz class to allow for custom handling. - Introduced a method in QuizModel to retrieve the full image URL for quiz answers, improving code reusability and clarity. - Updated the quiz attempt details view to utilize the new method for fetching image URLs, streamlining the code and enhancing maintainability. --- classes/Quiz.php | 4 ++++ models/QuizModel.php | 32 +++++++++++++++++++++++++++++++- views/quiz/attempt-details.php | 16 ++-------------- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 3badbc3ef9..2f8729c3df 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -778,6 +778,10 @@ function ( $ans ) { $answers_data = apply_filters( 'tutor_filter_quiz_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id ); + if ( 'draw_image' === $question_type ) { + $answers_data = apply_filters( 'tutor_filter_draw_image_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id ); + } + // For Pro-powered draw-image questions, adjust total marks after // add-ons have had a chance to modify achieved_mark via filters. if ( 'draw_image' === $question_type ) { diff --git a/models/QuizModel.php b/models/QuizModel.php index 5b34ff13c7..06665f84c0 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -1131,10 +1131,40 @@ public static function get_question_answers( $question_id, $question_type = null $answer->image_url = wp_get_attachment_url( $answer->image_id ); } } - return $answers; } + /** + * Get full image URL for a quiz answer. + * + * Uses attachment ID if present; falls back to stored image URL. + * + * @since 4.0.0 + * + * @param object $answer Quiz answer object. + * @param string $size Image size to retrieve. Default full. + * + * @return string + */ + public static function get_answer_image_url( $answer, $size = 'full' ) { + if ( empty( $answer ) ) { + return ''; + } + + if ( ! empty( $answer->image_id ) ) { + $url = wp_get_attachment_image_url( $answer->image_id, $size ); + if ( $url ) { + return $url; + } + } + + if ( ! empty( $answer->image_url ) ) { + return $answer->image_url; + } + + return ''; + } + /** * Get next answer order SL no * diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php index acbae01a2f..d6a3a240d7 100644 --- a/views/quiz/attempt-details.php +++ b/views/quiz/attempt-details.php @@ -540,14 +540,7 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an if ( $given_mask_is_url ) { $draw_image_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, false ); $instructor_answer = is_array( $draw_image_answers ) && ! empty( $draw_image_answers ) ? reset( $draw_image_answers ) : null; - $given_bg_url = ''; - if ( $instructor_answer ) { - if ( ! empty( $instructor_answer->image_id ) ) { - $given_bg_url = wp_get_attachment_image_url( $instructor_answer->image_id, 'full' ); - } elseif ( ! empty( $instructor_answer->image_url ) ) { - $given_bg_url = $instructor_answer->image_url; - } - } + $given_bg_url = QuizModel::get_answer_image_url( $instructor_answer ); echo '
'; echo '

' . esc_html__( 'Your drawing:', 'tutor' ) . '

'; echo '
'; @@ -741,12 +734,7 @@ function( $ans ) { $ref_mask_is_url = is_string( $ref_mask ) && false !== wp_http_validate_url( $ref_mask ); if ( $instructor_answer && $ref_mask_is_url ) { - $ref_bg = ''; - if ( ! empty( $instructor_answer->image_id ) ) { - $ref_bg = wp_get_attachment_image_url( $instructor_answer->image_id, 'full' ); - } elseif ( ! empty( $instructor_answer->image_url ) ) { - $ref_bg = $instructor_answer->image_url; - } + $ref_bg = QuizModel::get_answer_image_url( $instructor_answer ); echo '
'; echo '

' . esc_html__( 'Reference (correct answer zones):', 'tutor' ) . '

'; if ( $ref_bg ) { From ebb9640d1e5d430bd921c3b2bd1b871dc9d22702 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 5 Feb 2026 11:52:48 +0600 Subject: [PATCH 12/19] refactor: improve draw image handling in Quiz and CourseModel - Enhanced the deletion process for draw image files by collecting file paths before removing quiz attempts and questions, ensuring no orphaned files remain. - Introduced methods in QuizModel to retrieve and delete draw image file paths associated with quiz attempts and questions, improving code organization and maintainability. - Updated the DrawImage component to integrate with the shared draw-on-image API, enhancing the drawing functionality for instructors. --- .../curriculum/question-types/DrawImage.tsx | 236 ++++++++--------- classes/Quiz.php | 20 ++ models/CourseModel.php | 20 ++ models/QuizModel.php | 243 +++++++++++++++++- 4 files changed, 376 insertions(+), 143 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx index 70a26c7a72..fcb91b2d83 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx @@ -19,7 +19,27 @@ import { nanoid } from '@TutorShared/utils/util'; import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; import { type QuizForm } from '@CourseBuilderServices/quiz'; -const BRUSH_SIZE = 15; +/** Shared draw-on-image API from Tutor Pro (window.TutorDrawOnImage). */ +interface TutorDrawOnImageAPI { + init: (options: { + image: HTMLImageElement; + canvas: HTMLCanvasElement; + brushSize?: number; + strokeStyle?: string; + initialMaskUrl?: string; + onMaskChange?: (value: string) => void; + }) => { destroy: () => void }; + DEFAULT_BRUSH_SIZE?: number; + DEFAULT_STROKE_STYLE?: string; +} + +declare global { + interface Window { + TutorDrawOnImage?: TutorDrawOnImageAPI; + } +} + +const INSTRUCTOR_STROKE_STYLE = 'rgba(255, 0, 0, 0.9)'; const DrawImage = () => { const form = useFormContext(); @@ -33,12 +53,11 @@ const DrawImage = () => { defaultValue: [] as QuizQuestionOption[], }) as QuizQuestionOption[]; - const [isDrawing, setIsDrawing] = useState(false); const [isDrawModeActive, setIsDrawModeActive] = useState(false); const imageRef = useRef(null); const canvasRef = useRef(null); - const lastPointRef = useRef<{ x: number; y: number } | null>(null); + const drawInstanceRef = useRef<{ destroy: () => void } | null>(null); // Ensure there is always a single option for this question type. useEffect(() => { @@ -102,7 +121,8 @@ const DrawImage = () => { : null, }); - const syncCanvasWithImage = useCallback((maskUrl?: string) => { + /** Display-only: sync canvas size and draw saved mask when not in draw mode. */ + const syncCanvasDisplay = useCallback((maskUrl?: string) => { const img = imageRef.current; const canvas = canvasRef.current; @@ -141,11 +161,6 @@ const DrawImage = () => { } ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.strokeStyle = 'rgba(255, 0, 0, 0.9)'; - ctx.fillStyle = 'rgba(255, 0, 0, 0.9)'; - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; - ctx.lineWidth = BRUSH_SIZE; if (maskUrl) { const maskImg = new Image(); @@ -156,157 +171,98 @@ const DrawImage = () => { } }, []); + // Display-only sync when not in draw mode (saved mask + canvas size). useEffect(() => { - syncCanvasWithImage(option?.answer_two_gap_match || undefined); - }, [option?.image_url, option?.answer_two_gap_match, syncCanvasWithImage]); + if (isDrawModeActive) { + return; + } + syncCanvasDisplay(option?.answer_two_gap_match || undefined); + }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); useEffect(() => { + if (isDrawModeActive) { + return; + } const img = imageRef.current; if (!img) { return; } - const handleLoad = () => { - syncCanvasWithImage(option?.answer_two_gap_match || undefined); + syncCanvasDisplay(option?.answer_two_gap_match || undefined); }; - img.addEventListener('load', handleLoad); - return () => { img.removeEventListener('load', handleLoad); }; - }, [option?.answer_two_gap_match, syncCanvasWithImage]); + }, [isDrawModeActive, option?.answer_two_gap_match, syncCanvasDisplay]); useEffect(() => { + if (isDrawModeActive) { + return; + } const img = imageRef.current; const canvas = canvasRef.current; if (!img || !canvas) { return; } - const container = img.parentElement; if (!container) { return; } - const resizeObserver = new ResizeObserver(() => { - syncCanvasWithImage(option?.answer_two_gap_match || undefined); + syncCanvasDisplay(option?.answer_two_gap_match || undefined); }); - resizeObserver.observe(container); - return () => { resizeObserver.disconnect(); }; - }, [option?.image_url, option?.answer_two_gap_match, syncCanvasWithImage]); + }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); + // Wire to shared draw-on-image module when draw mode is active (Tutor Pro). useEffect(() => { + if (!isDrawModeActive || !option?.image_url) { + return; + } + const img = imageRef.current; const canvas = canvasRef.current; - if (!canvas) { + const api = typeof window !== 'undefined' ? window.TutorDrawOnImage : undefined; + if (!img || !canvas || !api?.init) { return; } - - const getCoords = (event: MouseEvent | TouchEvent) => { - const rect = canvas.getBoundingClientRect(); - const scaleX = canvas.width / rect.width; - const scaleY = canvas.height / rect.height; - - let clientX: number; - let clientY: number; - - if (event instanceof TouchEvent) { - const touch = event.touches[0] || event.changedTouches[0]; - clientX = touch.clientX; - clientY = touch.clientY; - } else { - clientX = event.clientX; - clientY = event.clientY; - } - - return { - x: (clientX - rect.left) * scaleX, - y: (clientY - rect.top) * scaleY, - }; - }; - - const handlePointerDown = (event: MouseEvent | TouchEvent) => { - if (!isDrawModeActive) return; - - event.preventDefault(); - if (!option?.image_url) { - return; - } - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const coords = getCoords(event); - if (!coords) return; - - setIsDrawing(true); - lastPointRef.current = coords; - - ctx.beginPath(); - ctx.arc(coords.x, coords.y, BRUSH_SIZE / 2, 0, Math.PI * 2); - ctx.fill(); - }; - - const handlePointerMove = (event: MouseEvent | TouchEvent) => { - if (!isDrawing) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - const coords = getCoords(event); - const lastPoint = lastPointRef.current; - if (!coords || !lastPoint) return; - - ctx.beginPath(); - ctx.moveTo(lastPoint.x, lastPoint.y); - ctx.lineTo(coords.x, coords.y); - ctx.stroke(); - - lastPointRef.current = coords; - }; - - const handlePointerUp = (event: MouseEvent | TouchEvent) => { - event.preventDefault(); - setIsDrawing(false); - lastPointRef.current = null; + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + const brushSize = api.DEFAULT_BRUSH_SIZE ?? 15; + const instance = api.init({ + image: img, + canvas, + brushSize, + strokeStyle: INSTRUCTOR_STROKE_STYLE, + initialMaskUrl: option.answer_two_gap_match || undefined, + }); + drawInstanceRef.current = instance; + return () => { + instance.destroy(); + drawInstanceRef.current = null; }; + }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match]); - const handleMouseDown = (event: MouseEvent) => handlePointerDown(event); - const handleMouseMove = (event: MouseEvent) => handlePointerMove(event); - const handleMouseUp = (event: MouseEvent) => handlePointerUp(event); - - const handleTouchStart = (event: TouchEvent) => handlePointerDown(event); - const handleTouchMove = (event: TouchEvent) => handlePointerMove(event); - const handleTouchEnd = (event: TouchEvent) => handlePointerUp(event); - - canvas.addEventListener('mousedown', handleMouseDown); - canvas.addEventListener('mousemove', handleMouseMove); - canvas.addEventListener('mouseup', handleMouseUp); - canvas.addEventListener('mouseleave', handleMouseUp); - - canvas.addEventListener('touchstart', handleTouchStart, { passive: false }); - canvas.addEventListener('touchmove', handleTouchMove, { passive: false }); - canvas.addEventListener('touchend', handleTouchEnd, { passive: false }); - + // Cleanup shared instance on unmount. + useEffect(() => { return () => { - canvas.removeEventListener('mousedown', handleMouseDown); - canvas.removeEventListener('mousemove', handleMouseMove); - canvas.removeEventListener('mouseup', handleMouseUp); - canvas.removeEventListener('mouseleave', handleMouseUp); - - canvas.removeEventListener('touchstart', handleTouchStart); - canvas.removeEventListener('touchmove', handleTouchMove); - canvas.removeEventListener('touchend', handleTouchEnd); + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } }; - }, [isDrawing, isDrawModeActive, option?.image_url]); + }, []); const handleSave = () => { const canvas = canvasRef.current; - if (!canvas || !option) return; + if (!canvas || !option) { + return; + } const dataUrl = canvas.toDataURL('image/png'); const blank = document.createElement('canvas'); @@ -323,18 +279,29 @@ const DrawImage = () => { is_saved: true, }; form.setValue(answersPath, [updated]); + + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + setIsDrawModeActive(false); }; const handleClear = () => { - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } - ctx.clearRect(0, 0, canvas.width, canvas.height); + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx?.clearRect(0, 0, canvas.width, canvas.height); + } - if (!option) return; + if (!option) { + return; + } const updated: QuizQuestionOption = { ...option, @@ -345,22 +312,23 @@ const DrawImage = () => { is_saved: true, }; form.setValue(answersPath, [updated]); + setIsDrawModeActive(false); }; const handleDraw = () => { setIsDrawModeActive(true); - - const canvas = canvasRef.current; - if (!canvas) return; - - const ctx = canvas.getContext('2d'); - if (!ctx) return; - - ctx.clearRect(0, 0, canvas.width, canvas.height); }; const clearImage = () => { - if (!option) return; + if (!option) { + return; + } + + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + setIsDrawModeActive(false); const updated: QuizQuestionOption = { ...option, diff --git a/classes/Quiz.php b/classes/Quiz.php index 2f8729c3df..147c775973 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -1105,9 +1105,27 @@ public function ajax_quiz_delete() { do_action( 'tutor_delete_quiz_before', $quiz_id ); + // Collect draw_image file paths before deleting rows (files deleted after DB for safety). + $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ), 'attempt_id', -1 ); + $attempt_file_paths = array(); + if ( ! empty( $attempts_for_quiz ) ) { + $attempt_ids = array_map( + function ( $row ) { + return (int) $row->attempt_id; + }, + $attempts_for_quiz + ); + $attempt_file_paths = QuizModel::get_draw_image_file_paths_for_attempts( $attempt_ids ); + } + $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) ); $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $quiz_id ) ); + QuizModel::delete_files_by_paths( $attempt_file_paths ); + + // Collect instructor draw_image file paths before deleting question data. + $quiz_file_paths = QuizModel::get_draw_image_file_paths_for_quiz( $quiz_id ); + $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $quiz_id ) ); if ( is_array( $questions_ids ) && count( $questions_ids ) ) { @@ -1124,6 +1142,8 @@ public function ajax_quiz_delete() { $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $quiz_id ) ); + QuizModel::delete_files_by_paths( $quiz_file_paths ); + wp_delete_post( $quiz_id, true ); do_action( 'tutor_delete_quiz_after', $quiz_id ); diff --git a/models/CourseModel.php b/models/CourseModel.php index 062b10920b..51dcd841f3 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -525,11 +525,29 @@ public function delete_course_data( $post_id ) { * Delete Quiz data */ if ( get_post_type( $content_id ) === 'tutor_quiz' ) { + // Collect draw_image file paths before deleting rows (files deleted after DB for safety). + $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $content_id ), 'attempt_id', -1 ); + $attempt_file_paths = array(); + if ( ! empty( $attempts_for_quiz ) ) { + $attempt_ids = array_map( + function ( $row ) { + return (int) $row->attempt_id; + }, + $attempts_for_quiz + ); + $attempt_file_paths = QuizModel::get_draw_image_file_paths_for_attempts( $attempt_ids ); + } + $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $content_id ) ); $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempt_answers', array( 'quiz_id' => $content_id ) ); + QuizModel::delete_files_by_paths( $attempt_file_paths ); + do_action( 'tutor_before_delete_quiz_content', $content_id, null ); + // Collect instructor draw_image file paths before deleting question data. + $quiz_file_paths = QuizModel::get_draw_image_file_paths_for_quiz( $content_id ); + $questions_ids = $wpdb->get_col( $wpdb->prepare( "SELECT question_id FROM {$wpdb->prefix}tutor_quiz_questions WHERE quiz_id = %d ", $content_id ) ); if ( is_array( $questions_ids ) && count( $questions_ids ) ) { $in_question_ids = "'" . implode( "','", $questions_ids ) . "'"; @@ -537,6 +555,8 @@ public function delete_course_data( $post_id ) { $wpdb->query( "DELETE FROM {$wpdb->prefix}tutor_quiz_question_answers WHERE belongs_question_id IN({$in_question_ids}) " ); } $wpdb->delete( $wpdb->prefix . 'tutor_quiz_questions', array( 'quiz_id' => $content_id ) ); + + QuizModel::delete_files_by_paths( $quiz_file_paths ); } /** diff --git a/models/QuizModel.php b/models/QuizModel.php index 06665f84c0..2824e79d0b 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -545,22 +545,247 @@ public static function get_quiz_attempts( $start = 0, $limit = 10, $search_filte * @return void */ public static function delete_quiz_attempt( $attempt_ids ) { - global $wpdb; - // Singlular to array. ! is_array( $attempt_ids ) ? $attempt_ids = array( $attempt_ids ) : 0; + $attempt_ids = array_map( 'absint', array_filter( $attempt_ids ) ); if ( count( $attempt_ids ) ) { - $attempt_ids = implode( ',', $attempt_ids ); + // Collect draw_image file paths before deleting rows (files deleted after DB for safety). + // Scoped to draw_image only: other question types are never touched (see get_draw_image_file_paths_for_attempts). + $attempt_file_paths = self::get_draw_image_file_paths_for_attempts( $attempt_ids ); + + // Delete attempt answers (child) then attempts (parent); use QueryHelper for bulk delete. + QueryHelper::bulk_delete( + QueryHelper::prepare_table_name( 'tutor_quiz_attempt_answers' ), + array( 'quiz_attempt_id' => $attempt_ids ) + ); + QueryHelper::bulk_delete( + QueryHelper::prepare_table_name( 'tutor_quiz_attempts' ), + array( 'attempt_id' => $attempt_ids ) + ); + + self::delete_files_by_paths( $attempt_file_paths ); + + do_action( 'tutor_quiz/attempt_deleted', implode( ',', $attempt_ids ) ); + } + } + + /** + * Get file paths of draw_image attempt mask files for given attempt(s). + * Used to delete files after DB rows are removed (e.g. when quiz is deleted). + * + * @since 4.0.0 + * + * @param int[] $attempt_ids Array of quiz attempt IDs. + * + * @return string[] Array of absolute file paths. + */ + public static function get_draw_image_file_paths_for_attempts( array $attempt_ids ) { + $paths = array(); + if ( empty( $attempt_ids ) ) { + return $paths; + } + + $upload_dir = wp_upload_dir(); + if ( ! empty( $upload_dir['error'] ) ) { + return $paths; + } + + $uploads_base_url = trailingslashit( $upload_dir['baseurl'] ); + $uploads_base_dir = trailingslashit( $upload_dir['basedir'] ); + $quiz_image_url = $uploads_base_url . 'tutor/quiz-image/'; + + $attempt_answers = QueryHelper::get_all( + 'tutor_quiz_attempt_answers', + array( 'quiz_attempt_id' => $attempt_ids ), + 'attempt_answer_id', + -1 + ); + + if ( empty( $attempt_answers ) ) { + return $paths; + } + + $question_ids = array_unique( + array_filter( + array_map( + function ( $row ) { + return isset( $row->question_id ) ? (int) $row->question_id : null; + }, + $attempt_answers + ) + ) + ); + + if ( empty( $question_ids ) ) { + return $paths; + } + + $draw_image_questions = QueryHelper::get_all( + 'tutor_quiz_questions', + array( + 'question_id' => $question_ids, + 'question_type' => 'draw_image', + ), + 'question_id', + -1 + ); + + $draw_image_question_ids = array_flip( + array_map( + function ( $row ) { + return (int) $row->question_id; + }, + $draw_image_questions + ) + ); + + foreach ( $attempt_answers as $row ) { + $question_id = isset( $row->question_id ) ? (int) $row->question_id : 0; + if ( ! isset( $draw_image_question_ids[ $question_id ] ) ) { + continue; + } + $url = is_string( $row->given_answer ?? '' ) ? trim( $row->given_answer ) : ''; + if ( '' === $url || strpos( $url, 'http' ) !== 0 ) { + continue; + } + if ( strpos( $url, $quiz_image_url ) !== 0 ) { + continue; + } + $path = str_replace( $uploads_base_url, $uploads_base_dir, $url ); + if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) { + $paths[] = $path; + } + } + + return $paths; + } + + /** + * Delete draw_image question mask files for given quiz attempt(s). + * + * When attempts are deleted, image files stored in uploads/tutor/quiz-image + * for draw_image answers must be removed to avoid orphaned files. + * Only the draw_image question type uses this folder; other question types are not affected. + * + * @since 4.0.0 + * + * @param int[] $attempt_ids Array of quiz attempt IDs. + * + * @return void + */ + public static function delete_draw_image_files_for_attempts( array $attempt_ids ) { + $paths = self::get_draw_image_file_paths_for_attempts( $attempt_ids ); + self::delete_files_by_paths( $paths ); + } + + /** + * Delete files by absolute path (e.g. after DB rows have been removed). + * + * @since 4.0.0 + * + * @param string[] $paths Array of absolute file paths. + * + * @return void + */ + public static function delete_files_by_paths( array $paths ) { + foreach ( $paths as $path ) { + if ( is_string( $path ) && '' !== $path && is_file( $path ) && is_readable( $path ) ) { + wp_delete_file( $path ); + } + } + } + + /** + * Get file paths of draw_image instructor mask files for a quiz. + * Used to delete files after DB rows are removed (e.g. when quiz is deleted). + * + * @since 4.0.0 + * + * @param int $quiz_id Quiz post ID. + * + * @return string[] Array of absolute file paths. + */ + public static function get_draw_image_file_paths_for_quiz( $quiz_id ) { + $paths = array(); + $quiz_id = (int) $quiz_id; + if ( $quiz_id <= 0 ) { + return $paths; + } + + $upload_dir = wp_upload_dir(); + if ( ! empty( $upload_dir['error'] ) ) { + return $paths; + } + + $uploads_base_url = trailingslashit( $upload_dir['baseurl'] ); + $uploads_base_dir = trailingslashit( $upload_dir['basedir'] ); + $quiz_image_url = $uploads_base_url . 'tutor/quiz-image/'; - //phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared - // Deleting attempt (comment), child attempt and attempt meta (comment meta). - $wpdb->query( "DELETE FROM {$wpdb->prefix}tutor_quiz_attempts WHERE attempt_id IN($attempt_ids)" ); - $wpdb->query( "DELETE FROM {$wpdb->prefix}tutor_quiz_attempt_answers WHERE quiz_attempt_id IN($attempt_ids)" ); - //phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared + $draw_image_questions = QueryHelper::get_all( + 'tutor_quiz_questions', + array( + 'quiz_id' => $quiz_id, + 'question_type' => 'draw_image', + ), + 'question_id', + -1 + ); + + if ( empty( $draw_image_questions ) ) { + return $paths; + } + + $question_ids = array_map( + function ( $row ) { + return (int) $row->question_id; + }, + $draw_image_questions + ); - do_action( 'tutor_quiz/attempt_deleted', $attempt_ids ); + $question_answers = QueryHelper::get_all( + 'tutor_quiz_question_answers', + array( + 'belongs_question_id' => $question_ids, + 'belongs_question_type' => 'draw_image', + ), + 'answer_id', + -1 + ); + + foreach ( $question_answers as $row ) { + $url = is_string( $row->answer_two_gap_match ?? '' ) ? trim( $row->answer_two_gap_match ) : ''; + if ( '' === $url || strpos( $url, 'http' ) !== 0 ) { + continue; + } + if ( strpos( $url, $quiz_image_url ) !== 0 ) { + continue; + } + $path = str_replace( $uploads_base_url, $uploads_base_dir, $url ); + if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) { + $paths[] = $path; + } } + + return $paths; + } + + /** + * Delete draw_image instructor reference mask files for a quiz. + * + * When a quiz is deleted, image files stored in uploads/tutor/quiz-image + * for draw_image question answers (instructor masks) must be removed. + * Only the draw_image question type uses this folder; other question types are not affected. + * + * @since 4.0.0 + * + * @param int $quiz_id Quiz post ID. + * + * @return void + */ + public static function delete_draw_image_files_for_quiz( $quiz_id ) { + $paths = self::get_draw_image_file_paths_for_quiz( $quiz_id ); + self::delete_files_by_paths( $paths ); } /** From 0942d3fed6f7ae077ad88ceb7c6dff07996b87a8 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 5 Feb 2026 12:11:59 +0600 Subject: [PATCH 13/19] refactor: optimize draw image URL resolution in QuizModel - Simplified the process of resolving draw image URLs to absolute file paths by introducing a dedicated method, improving code organization and readability. - Enhanced the handling of quiz attempt IDs to ensure only valid IDs are processed, reducing potential errors. - Updated the logic to utilize a single query for fetching relevant data, streamlining the retrieval of draw image answers and improving performance. --- models/QuizModel.php | 125 +++++++++++++++++++------------------------ 1 file changed, 55 insertions(+), 70 deletions(-) diff --git a/models/QuizModel.php b/models/QuizModel.php index 2824e79d0b..fa6640fbed 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -595,70 +595,37 @@ public static function get_draw_image_file_paths_for_attempts( array $attempt_id $uploads_base_dir = trailingslashit( $upload_dir['basedir'] ); $quiz_image_url = $uploads_base_url . 'tutor/quiz-image/'; - $attempt_answers = QueryHelper::get_all( - 'tutor_quiz_attempt_answers', - array( 'quiz_attempt_id' => $attempt_ids ), - 'attempt_answer_id', - -1 - ); - - if ( empty( $attempt_answers ) ) { - return $paths; - } - - $question_ids = array_unique( - array_filter( - array_map( - function ( $row ) { - return isset( $row->question_id ) ? (int) $row->question_id : null; - }, - $attempt_answers - ) - ) - ); - - if ( empty( $question_ids ) ) { + $attempt_ids = array_filter( array_map( 'absint', $attempt_ids ) ); + if ( empty( $attempt_ids ) ) { return $paths; } - $draw_image_questions = QueryHelper::get_all( - 'tutor_quiz_questions', + $response = QueryHelper::get_joined_data( + 'tutor_quiz_attempt_answers AS a', array( - 'question_id' => $question_ids, - 'question_type' => 'draw_image', + array( + 'type' => 'INNER', + 'table' => 'tutor_quiz_questions AS q', + 'on' => 'a.question_id = q.question_id', + ), ), - 'question_id', - -1 - ); - - $draw_image_question_ids = array_flip( - array_map( - function ( $row ) { - return (int) $row->question_id; - }, - $draw_image_questions - ) + array( 'a.given_answer' ), + array( + 'a.quiz_attempt_id' => $attempt_ids, + 'q.question_type' => 'draw_image', + ), + array(), + '', + -1, + 0 ); - foreach ( $attempt_answers as $row ) { - $question_id = isset( $row->question_id ) ? (int) $row->question_id : 0; - if ( ! isset( $draw_image_question_ids[ $question_id ] ) ) { - continue; - } - $url = is_string( $row->given_answer ?? '' ) ? trim( $row->given_answer ) : ''; - if ( '' === $url || strpos( $url, 'http' ) !== 0 ) { - continue; - } - if ( strpos( $url, $quiz_image_url ) !== 0 ) { - continue; - } - $path = str_replace( $uploads_base_url, $uploads_base_dir, $url ); - if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) { - $paths[] = $path; - } + $rows = isset( $response['results'] ) && is_array( $response['results'] ) ? $response['results'] : array(); + if ( empty( $rows ) ) { + return $paths; } - return $paths; + return self::resolve_draw_image_urls_to_paths( $rows, 'given_answer', $uploads_base_url, $uploads_base_dir, $quiz_image_url ); } /** @@ -696,6 +663,38 @@ public static function delete_files_by_paths( array $paths ) { } } + /** + * Resolve tutor/quiz-image URLs from rows to absolute file paths. + * Only includes paths for files that exist and are readable. + * + * @since 4.0.0 + * + * @param array $rows Rows with a URL in the given property (e.g. attempt answers or question answers). + * @param string $url_property Property name on each row (e.g. 'given_answer', 'answer_two_gap_match'). + * @param string $uploads_base_url Base URL for uploads (trailingslashit). + * @param string $uploads_base_dir Base dir for uploads (trailingslashit). + * @param string $quiz_image_url Quiz-image URL prefix (uploads_base_url . 'tutor/quiz-image/'). + * + * @return string[] Absolute file paths. + */ + private static function resolve_draw_image_urls_to_paths( array $rows, $url_property, $uploads_base_url, $uploads_base_dir, $quiz_image_url ) { + $paths = array(); + foreach ( $rows as $row ) { + $url = is_string( $row->$url_property ?? '' ) ? trim( $row->$url_property ) : ''; + if ( '' === $url || strpos( $url, 'http' ) !== 0 ) { + continue; + } + if ( strpos( $url, $quiz_image_url ) !== 0 ) { + continue; + } + $path = str_replace( $uploads_base_url, $uploads_base_dir, $url ); + if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) { + $paths[] = $path; + } + } + return $paths; + } + /** * Get file paths of draw_image instructor mask files for a quiz. * Used to delete files after DB rows are removed (e.g. when quiz is deleted). @@ -753,21 +752,7 @@ function ( $row ) { -1 ); - foreach ( $question_answers as $row ) { - $url = is_string( $row->answer_two_gap_match ?? '' ) ? trim( $row->answer_two_gap_match ) : ''; - if ( '' === $url || strpos( $url, 'http' ) !== 0 ) { - continue; - } - if ( strpos( $url, $quiz_image_url ) !== 0 ) { - continue; - } - $path = str_replace( $uploads_base_url, $uploads_base_dir, $url ); - if ( '' !== $path && is_file( $path ) && is_readable( $path ) ) { - $paths[] = $path; - } - } - - return $paths; + return self::resolve_draw_image_urls_to_paths( $question_answers, 'answer_two_gap_match', $uploads_base_url, $uploads_base_dir, $quiz_image_url ); } /** From 89743d7c183bbb0be303b30ea6b9ee2c0315994f Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 5 Feb 2026 12:49:29 +0600 Subject: [PATCH 14/19] refactor: enhance quiz attempt file path handling - Introduced a new filter to collect file paths for deletion from all question types that store files, improving the flexibility of the deletion process. - Updated comments for clarity, specifying that file paths are collected from various question types, not just draw_image. - Refactored methods in QuizModel to streamline the retrieval of file paths for deletion, enhancing code organization and maintainability. --- classes/Quiz.php | 7 +++++-- models/CourseModel.php | 4 ++-- models/QuizModel.php | 46 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/classes/Quiz.php b/classes/Quiz.php index 147c775973..022aa3cbd6 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -127,6 +127,9 @@ public function __construct( $register_hooks = true ) { */ add_action( 'wp_ajax_tutor_attempt_delete', array( $this, 'attempt_delete' ) ); + // Collect file paths for attempt deletion: draw_image (and others via same filter). + add_filter( 'tutor_quiz/attempt_file_paths_for_deletion', array( QuizModel::class, 'add_draw_image_attempt_file_paths' ), 10, 2 ); + add_action( 'tutor_quiz/answer/review/after', array( $this, 'do_auto_course_complete' ), 10, 3 ); // Add quiz title as nav item & render single content on the learning area. @@ -1105,7 +1108,7 @@ public function ajax_quiz_delete() { do_action( 'tutor_delete_quiz_before', $quiz_id ); - // Collect draw_image file paths before deleting rows (files deleted after DB for safety). + // Collect file paths from all question types that store files before deleting rows (files deleted after DB for safety). $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ), 'attempt_id', -1 ); $attempt_file_paths = array(); if ( ! empty( $attempts_for_quiz ) ) { @@ -1115,7 +1118,7 @@ function ( $row ) { }, $attempts_for_quiz ); - $attempt_file_paths = QuizModel::get_draw_image_file_paths_for_attempts( $attempt_ids ); + $attempt_file_paths = QuizModel::get_attempt_file_paths_for_deletion( $attempt_ids ); } $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $quiz_id ) ); diff --git a/models/CourseModel.php b/models/CourseModel.php index 51dcd841f3..186b89c42e 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -525,7 +525,7 @@ public function delete_course_data( $post_id ) { * Delete Quiz data */ if ( get_post_type( $content_id ) === 'tutor_quiz' ) { - // Collect draw_image file paths before deleting rows (files deleted after DB for safety). + // Collect file paths from all question types that store files before deleting rows (files deleted after DB for safety). $attempts_for_quiz = QueryHelper::get_all( 'tutor_quiz_attempts', array( 'quiz_id' => $content_id ), 'attempt_id', -1 ); $attempt_file_paths = array(); if ( ! empty( $attempts_for_quiz ) ) { @@ -535,7 +535,7 @@ function ( $row ) { }, $attempts_for_quiz ); - $attempt_file_paths = QuizModel::get_draw_image_file_paths_for_attempts( $attempt_ids ); + $attempt_file_paths = QuizModel::get_attempt_file_paths_for_deletion( $attempt_ids ); } $wpdb->delete( $wpdb->prefix . 'tutor_quiz_attempts', array( 'quiz_id' => $content_id ) ); diff --git a/models/QuizModel.php b/models/QuizModel.php index fa6640fbed..a2a94fc95e 100644 --- a/models/QuizModel.php +++ b/models/QuizModel.php @@ -550,9 +550,8 @@ public static function delete_quiz_attempt( $attempt_ids ) { $attempt_ids = array_map( 'absint', array_filter( $attempt_ids ) ); if ( count( $attempt_ids ) ) { - // Collect draw_image file paths before deleting rows (files deleted after DB for safety). - // Scoped to draw_image only: other question types are never touched (see get_draw_image_file_paths_for_attempts). - $attempt_file_paths = self::get_draw_image_file_paths_for_attempts( $attempt_ids ); + // Collect file paths from all question types that store files (e.g. draw_image). Files deleted after DB for safety. + $attempt_file_paths = self::get_attempt_file_paths_for_deletion( $attempt_ids ); // Delete attempt answers (child) then attempts (parent); use QueryHelper for bulk delete. QueryHelper::bulk_delete( @@ -570,9 +569,48 @@ public static function delete_quiz_attempt( $attempt_ids ) { } } + /** + * Get all file paths that should be deleted when the given attempt(s) are removed. + * All question types that store files (draw_image, pin_image, etc.) add their paths + * via the tutor_quiz/attempt_file_paths_for_deletion filter. + * + * @since 4.0.0 + * + * @param int[] $attempt_ids Array of quiz attempt IDs. + * + * @return string[] Array of absolute file paths. + */ + public static function get_attempt_file_paths_for_deletion( array $attempt_ids ) { + $paths = array(); + /** + * Question types that store files add paths to delete when attempts are removed. + * Core registers draw_image via add_draw_image_attempt_file_paths; other types (e.g. pin_image) add their own. + * + * @param string[] $file_paths Absolute file paths collected so far. + * @param int[] $attempt_ids Quiz attempt IDs being deleted. + */ + $paths = apply_filters( 'tutor_quiz/attempt_file_paths_for_deletion', $paths, $attempt_ids ); + return is_array( $paths ) ? array_values( array_filter( array_unique( $paths ) ) ) : array(); + } + + /** + * Filter callback: add draw_image attempt file paths for deletion. + * Registered on tutor_quiz/attempt_file_paths_for_deletion so draw_image uses the same mechanism as other types. + * + * @since 4.0.0 + * + * @param string[] $file_paths Paths collected so far. + * @param int[] $attempt_ids Quiz attempt IDs being deleted. + * + * @return string[] + */ + public static function add_draw_image_attempt_file_paths( $file_paths, $attempt_ids ) { + return array_merge( (array) $file_paths, self::get_draw_image_file_paths_for_attempts( $attempt_ids ) ); + } + /** * Get file paths of draw_image attempt mask files for given attempt(s). - * Used to delete files after DB rows are removed (e.g. when quiz is deleted). + * Used by get_attempt_file_paths_for_deletion; also usable when only draw_image paths are needed. * * @since 4.0.0 * From 9bc088db144d65c4df00cc66c030b0dd966ae619 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 5 Feb 2026 13:11:43 +0600 Subject: [PATCH 15/19] refactor: streamline DrawImage component and introduce FormDrawImage - Refactored the DrawImage component to simplify its structure and improve integration with the shared draw-on-image API. - Introduced a new FormDrawImage component to encapsulate the drawing functionality, enhancing code organization and reusability. - Updated state management and effect hooks for better performance and clarity in handling drawing interactions. - Removed deprecated code and unnecessary complexity, focusing on a cleaner implementation for image drawing in quizzes. --- .../curriculum/question-types/DrawImage.tsx | 562 ++---------------- .../fields/quiz/questions/FormDrawImage.tsx | 531 +++++++++++++++++ 2 files changed, 567 insertions(+), 526 deletions(-) create mode 100644 assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx index fcb91b2d83..c1f1caf134 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx @@ -1,444 +1,66 @@ import { css } from '@emotion/react'; -import { __ } from '@wordpress/i18n'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { useFormContext, useWatch } from 'react-hook-form'; +import { useEffect } from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; -import Button from '@TutorShared/atoms/Button'; -import ImageInput from '@TutorShared/atoms/ImageInput'; -import SVGIcon from '@TutorShared/atoms/SVGIcon'; - -import { borderRadius, Breakpoint, colorTokens, spacing } from '@TutorShared/config/styles'; -import { typography } from '@TutorShared/config/typography'; -import Show from '@TutorShared/controls/Show'; -import useWPMedia from '@TutorShared/hooks/useWpMedia'; -import { calculateQuizDataStatus } from '@TutorShared/utils/quiz'; +import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; +import type { QuizForm } from '@CourseBuilderServices/quiz'; +import FormDrawImage from '@TutorShared/components/fields/quiz/questions/FormDrawImage'; +import { spacing } from '@TutorShared/config/styles'; import { styleUtils } from '@TutorShared/utils/style-utils'; import { QuizDataStatus, type QuizQuestionOption } from '@TutorShared/utils/types'; import { nanoid } from '@TutorShared/utils/util'; -import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; -import { type QuizForm } from '@CourseBuilderServices/quiz'; - -/** Shared draw-on-image API from Tutor Pro (window.TutorDrawOnImage). */ -interface TutorDrawOnImageAPI { - init: (options: { - image: HTMLImageElement; - canvas: HTMLCanvasElement; - brushSize?: number; - strokeStyle?: string; - initialMaskUrl?: string; - onMaskChange?: (value: string) => void; - }) => { destroy: () => void }; - DEFAULT_BRUSH_SIZE?: number; - DEFAULT_STROKE_STYLE?: string; -} - -declare global { - interface Window { - TutorDrawOnImage?: TutorDrawOnImageAPI; - } -} - -const INSTRUCTOR_STROKE_STYLE = 'rgba(255, 0, 0, 0.9)'; - const DrawImage = () => { const form = useFormContext(); - const { activeQuestionIndex, activeQuestionId } = useQuizModalContext(); + const { activeQuestionId, activeQuestionIndex, validationError, setValidationError } = useQuizModalContext(); const answersPath = `questions.${activeQuestionIndex}.question_answers` as 'questions.0.question_answers'; - const answers = useWatch({ + const { fields: optionsFields } = useFieldArray({ control: form.control, name: answersPath, - defaultValue: [] as QuizQuestionOption[], - }) as QuizQuestionOption[]; - - const [isDrawModeActive, setIsDrawModeActive] = useState(false); - - const imageRef = useRef(null); - const canvasRef = useRef(null); - const drawInstanceRef = useRef<{ destroy: () => void } | null>(null); + }); // Ensure there is always a single option for this question type. useEffect(() => { if (!activeQuestionId) { return; } - - if (!answers || answers.length === 0) { - const baseAnswer: QuizQuestionOption = { - _data_status: QuizDataStatus.NEW, - // Mark as saved so core validation doesn't block on this synthetic option. - is_saved: true, - answer_id: nanoid(), - belongs_question_id: activeQuestionId, - belongs_question_type: 'draw_image' as QuizQuestionOption['belongs_question_type'], - answer_title: '', - is_correct: '1', - image_id: undefined, - image_url: '', - answer_two_gap_match: '', - answer_view_format: 'draw_image', - answer_order: 0, - }; - - form.setValue(answersPath, [baseAnswer]); - } - }, [activeQuestionId, answers, answersPath, form]); - - const option = (answers && answers[0]) as QuizQuestionOption | undefined; - - const { openMediaLibrary, resetFiles } = useWPMedia({ - options: { - type: 'image', - }, - onChange: (file) => { - if (!option) { - return; - } - - if (file && !Array.isArray(file)) { - const { id, url } = file; - const updated: QuizQuestionOption = { - ...option, - ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { - _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, - }), - image_id: id, - image_url: url, - }; - - form.setValue(answersPath, [updated]); - } - }, - initialFiles: - option && option.image_id - ? { - id: Number(option.image_id), - url: option.image_url || '', - title: option.image_url || '', - } - : null, - }); - - /** Display-only: sync canvas size and draw saved mask when not in draw mode. */ - const syncCanvasDisplay = useCallback((maskUrl?: string) => { - const img = imageRef.current; - const canvas = canvasRef.current; - - if (!img || !canvas) { - return; - } - - if (!img.complete) { - return; - } - - const container = img.parentElement; - if (!container) { - return; - } - - const rect = container.getBoundingClientRect(); - const w = Math.round(rect.width); - const h = Math.round(rect.height); - - if (!w || !h) { - return; - } - - canvas.width = w; - canvas.height = h; - canvas.style.position = 'absolute'; - canvas.style.top = '0'; - canvas.style.left = '0'; - canvas.style.width = '100%'; - canvas.style.height = '100%'; - - const ctx = canvas.getContext('2d'); - if (!ctx) { - return; - } - - ctx.clearRect(0, 0, canvas.width, canvas.height); - - if (maskUrl) { - const maskImg = new Image(); - maskImg.onload = () => { - ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height); - }; - maskImg.src = maskUrl; - } - }, []); - - // Display-only sync when not in draw mode (saved mask + canvas size). - useEffect(() => { - if (isDrawModeActive) { - return; - } - syncCanvasDisplay(option?.answer_two_gap_match || undefined); - }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); - - useEffect(() => { - if (isDrawModeActive) { - return; - } - const img = imageRef.current; - if (!img) { - return; - } - const handleLoad = () => { - syncCanvasDisplay(option?.answer_two_gap_match || undefined); - }; - img.addEventListener('load', handleLoad); - return () => { - img.removeEventListener('load', handleLoad); - }; - }, [isDrawModeActive, option?.answer_two_gap_match, syncCanvasDisplay]); - - useEffect(() => { - if (isDrawModeActive) { - return; - } - const img = imageRef.current; - const canvas = canvasRef.current; - if (!img || !canvas) { - return; - } - const container = img.parentElement; - if (!container) { - return; - } - const resizeObserver = new ResizeObserver(() => { - syncCanvasDisplay(option?.answer_two_gap_match || undefined); - }); - resizeObserver.observe(container); - return () => { - resizeObserver.disconnect(); - }; - }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); - - // Wire to shared draw-on-image module when draw mode is active (Tutor Pro). - useEffect(() => { - if (!isDrawModeActive || !option?.image_url) { - return; - } - const img = imageRef.current; - const canvas = canvasRef.current; - const api = typeof window !== 'undefined' ? window.TutorDrawOnImage : undefined; - if (!img || !canvas || !api?.init) { + if (optionsFields.length > 0) { return; } - if (drawInstanceRef.current) { - drawInstanceRef.current.destroy(); - drawInstanceRef.current = null; - } - const brushSize = api.DEFAULT_BRUSH_SIZE ?? 15; - const instance = api.init({ - image: img, - canvas, - brushSize, - strokeStyle: INSTRUCTOR_STROKE_STYLE, - initialMaskUrl: option.answer_two_gap_match || undefined, - }); - drawInstanceRef.current = instance; - return () => { - instance.destroy(); - drawInstanceRef.current = null; - }; - }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match]); - - // Cleanup shared instance on unmount. - useEffect(() => { - return () => { - if (drawInstanceRef.current) { - drawInstanceRef.current.destroy(); - drawInstanceRef.current = null; - } - }; - }, []); - - const handleSave = () => { - const canvas = canvasRef.current; - if (!canvas || !option) { - return; - } - - const dataUrl = canvas.toDataURL('image/png'); - const blank = document.createElement('canvas'); - blank.width = canvas.width; - blank.height = canvas.height; - const isEmpty = dataUrl === blank.toDataURL(); - - const updated: QuizQuestionOption = { - ...option, - ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { - _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, - }), - answer_two_gap_match: isEmpty ? '' : dataUrl, - is_saved: true, - }; - form.setValue(answersPath, [updated]); - - if (drawInstanceRef.current) { - drawInstanceRef.current.destroy(); - drawInstanceRef.current = null; - } - setIsDrawModeActive(false); - }; - - const handleClear = () => { - if (drawInstanceRef.current) { - drawInstanceRef.current.destroy(); - drawInstanceRef.current = null; - } - - const canvas = canvasRef.current; - if (canvas) { - const ctx = canvas.getContext('2d'); - ctx?.clearRect(0, 0, canvas.width, canvas.height); - } - - if (!option) { - return; - } - - const updated: QuizQuestionOption = { - ...option, - ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { - _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, - }), - answer_two_gap_match: '', + const baseAnswer: QuizQuestionOption = { + _data_status: QuizDataStatus.NEW, is_saved: true, - }; - form.setValue(answersPath, [updated]); - setIsDrawModeActive(false); - }; - - const handleDraw = () => { - setIsDrawModeActive(true); - }; - - const clearImage = () => { - if (!option) { - return; - } - - if (drawInstanceRef.current) { - drawInstanceRef.current.destroy(); - drawInstanceRef.current = null; - } - setIsDrawModeActive(false); - - const updated: QuizQuestionOption = { - ...option, - ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { - _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, - }), + answer_id: nanoid(), + belongs_question_id: activeQuestionId, + belongs_question_type: 'draw_image' as QuizQuestionOption['belongs_question_type'], + answer_title: '', + is_correct: '1', image_id: undefined, image_url: '', + answer_two_gap_match: '', + answer_view_format: 'draw_image', + answer_order: 0, }; - - form.setValue(answersPath, [updated]); - resetFiles(); - - const canvas = canvasRef.current; - if (canvas) { - const ctx = canvas.getContext('2d'); - ctx?.clearRect(0, 0, canvas.width, canvas.height); - } - }; + form.setValue(answersPath, [baseAnswer]); + }, [activeQuestionId, optionsFields.length, answersPath, form]); return ( -
- {/* Section 1: Image upload only — one reference shown in Mark the correct area */} -
-
- + ( + -
-
- - {/* Section 2: Mark the correct area — single reference image + drawing canvas; Save / Clear / Draw buttons */} - -
-
- - - {__('Mark the correct area', 'tutor')} - -
-
- {__('Background - -
-
- - - -
-

- {__('Use the brush to draw on the image, then click Save to store the answer zone.', 'tutor')} -

- -

{__('Answer zone saved. Students will be graded against this area.', 'tutor')}

-
-
-
- - -

- {__( - 'Upload an image to define the area students must draw on. Then mark the correct zone in the next section.', - 'tutor', - )} -

-
+ )} + />
); }; @@ -446,120 +68,8 @@ const DrawImage = () => { export default DrawImage; const styles = { - wrapper: css` + optionWrapper: css` ${styleUtils.display.flex('column')}; - gap: ${spacing[24]}; padding-left: ${spacing[40]}; - - ${Breakpoint.smallMobile} { - padding-left: ${spacing[8]}; - } - `, - card: css` - ${styleUtils.display.flex('column')}; - gap: ${spacing[16]}; - padding: ${spacing[20]}; - background: ${colorTokens.surface.tutor}; - border: 1px solid ${colorTokens.stroke.border}; - border-radius: ${borderRadius.card}; - `, - imageInputWrapper: css` - max-width: 100%; - `, - imageInput: css` - border-radius: ${borderRadius.card}; - `, - answerHeader: css` - ${styleUtils.display.flex('row')}; - align-items: center; - justify-content: space-between; - gap: ${spacing[12]}; - `, - answerHeaderTitle: css` - ${typography.body('medium')}; - color: ${colorTokens.text.primary}; - ${styleUtils.display.flex('row')}; - align-items: center; - gap: ${spacing[8]}; - `, - headerIcon: css` - flex-shrink: 0; - color: ${colorTokens.text.subdued}; - `, - deleteButton: css` - display: inline-flex; - align-items: center; - justify-content: center; - padding: ${spacing[8]}; - background: transparent; - border: none; - border-radius: ${borderRadius.button}; - color: ${colorTokens.text.subdued}; - cursor: pointer; - transition: - color 0.15s ease, - background 0.15s ease; - - &:hover { - color: ${colorTokens.text.primary}; - background: ${colorTokens.background.hover}; - } - - &:focus-visible { - outline: 2px solid ${colorTokens.action.primary.focus}; - outline-offset: 2px; - } - `, - canvasInner: css` - position: relative; - display: inline-block; - border-radius: ${borderRadius.card}; - overflow: hidden; - - img { - display: block; - max-width: 100%; - height: auto; - } - `, - image: css` - display: block; - max-width: 100%; - height: auto; - `, - answerImage: css` - filter: grayscale(0.15); - `, - canvas: css` - position: absolute; - top: 0; - left: 0; - `, - canvasIdleMode: css` - pointer-events: none; - cursor: default; - `, - canvasDrawMode: css` - pointer-events: auto; - cursor: crosshair; - `, - actionsRow: css` - ${styleUtils.display.flex('row')}; - gap: ${spacing[12]}; - flex-wrap: wrap; - `, - brushHint: css` - ${typography.caption()}; - color: ${colorTokens.text.subdued}; - margin: 0; - `, - savedHint: css` - ${typography.caption()}; - color: ${colorTokens.text.success}; - margin: 0; - `, - placeholder: css` - ${typography.caption()}; - color: ${colorTokens.text.subdued}; `, }; diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx new file mode 100644 index 0000000000..2082eeaa9d --- /dev/null +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -0,0 +1,531 @@ +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import Button from '@TutorShared/atoms/Button'; +import ImageInput from '@TutorShared/atoms/ImageInput'; +import SVGIcon from '@TutorShared/atoms/SVGIcon'; + +import { borderRadius, Breakpoint, colorTokens, spacing } from '@TutorShared/config/styles'; +import { typography } from '@TutorShared/config/typography'; +import Show from '@TutorShared/controls/Show'; +import useWPMedia from '@TutorShared/hooks/useWpMedia'; +import type { FormControllerProps } from '@TutorShared/utils/form'; +import { calculateQuizDataStatus } from '@TutorShared/utils/quiz'; +import { styleUtils } from '@TutorShared/utils/style-utils'; +import { + type ID, + QuizDataStatus, + type QuizQuestionOption, + type QuizValidationErrorType, +} from '@TutorShared/utils/types'; +import { nanoid } from '@TutorShared/utils/util'; + +/** Shared draw-on-image API from Tutor Pro (window.TutorDrawOnImage). */ +interface TutorDrawOnImageAPI { + init: (options: { + image: HTMLImageElement; + canvas: HTMLCanvasElement; + brushSize?: number; + strokeStyle?: string; + initialMaskUrl?: string; + onMaskChange?: (value: string) => void; + }) => { destroy: () => void }; + DEFAULT_BRUSH_SIZE?: number; + DEFAULT_STROKE_STYLE?: string; +} + +declare global { + interface Window { + TutorDrawOnImage?: TutorDrawOnImageAPI; + } +} + +const INSTRUCTOR_STROKE_STYLE = 'rgba(255, 0, 0, 0.9)'; + +interface FormDrawImageProps extends FormControllerProps { + questionId: ID; + validationError?: { + message: string; + type: QuizValidationErrorType; + } | null; + setValidationError?: React.Dispatch< + React.SetStateAction<{ + message: string; + type: QuizValidationErrorType; + } | null> + >; +} + +const getDefaultOption = (questionId: ID): QuizQuestionOption => ({ + _data_status: QuizDataStatus.NEW, + is_saved: true, + answer_id: nanoid(), + belongs_question_id: questionId, + belongs_question_type: 'draw_image' as QuizQuestionOption['belongs_question_type'], + answer_title: '', + is_correct: '1', + image_id: undefined, + image_url: '', + answer_two_gap_match: '', + answer_view_format: 'draw_image', + answer_order: 0, +}); + +const FormDrawImage = ({ field, questionId }: FormDrawImageProps) => { + const option = (field.value ?? getDefaultOption(questionId)) as QuizQuestionOption; + + const [isDrawModeActive, setIsDrawModeActive] = useState(false); + + const imageRef = useRef(null); + const canvasRef = useRef(null); + const drawInstanceRef = useRef<{ destroy: () => void } | null>(null); + + const updateOption = useCallback( + (updated: QuizQuestionOption) => { + field.onChange(updated); + }, + [field], + ); + + /** Display-only: sync canvas size and draw saved mask when not in draw mode. */ + const syncCanvasDisplay = useCallback((maskUrl?: string) => { + const img = imageRef.current; + const canvas = canvasRef.current; + + if (!img || !canvas) { + return; + } + + if (!img.complete) { + return; + } + + const container = img.parentElement; + if (!container) { + return; + } + + const rect = container.getBoundingClientRect(); + const w = Math.round(rect.width); + const h = Math.round(rect.height); + + if (!w || !h) { + return; + } + + canvas.width = w; + canvas.height = h; + canvas.style.position = 'absolute'; + canvas.style.top = '0'; + canvas.style.left = '0'; + canvas.style.width = '100%'; + canvas.style.height = '100%'; + + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (maskUrl) { + const maskImg = new Image(); + maskImg.onload = () => { + ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height); + }; + maskImg.src = maskUrl; + } + }, []); + + const { openMediaLibrary, resetFiles } = useWPMedia({ + options: { + type: 'image', + }, + onChange: (file) => { + if (file && !Array.isArray(file)) { + const { id, url } = file; + const updated: QuizQuestionOption = { + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + image_id: id, + image_url: url, + }; + updateOption(updated); + } + }, + initialFiles: option.image_id + ? { + id: Number(option.image_id), + url: option.image_url || '', + title: option.image_url || '', + } + : null, + }); + + // Display-only sync when not in draw mode (saved mask + canvas size). + useEffect(() => { + if (isDrawModeActive) { + return; + } + syncCanvasDisplay(option?.answer_two_gap_match || undefined); + }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); + + useEffect(() => { + if (isDrawModeActive) { + return; + } + const img = imageRef.current; + if (!img) { + return; + } + const handleLoad = () => { + syncCanvasDisplay(option?.answer_two_gap_match || undefined); + }; + img.addEventListener('load', handleLoad); + return () => { + img.removeEventListener('load', handleLoad); + }; + }, [isDrawModeActive, option?.answer_two_gap_match, syncCanvasDisplay]); + + useEffect(() => { + if (isDrawModeActive) { + return; + } + const img = imageRef.current; + const canvas = canvasRef.current; + if (!img || !canvas) { + return; + } + const container = img.parentElement; + if (!container) { + return; + } + const resizeObserver = new ResizeObserver(() => { + syncCanvasDisplay(option?.answer_two_gap_match || undefined); + }); + resizeObserver.observe(container); + return () => { + resizeObserver.disconnect(); + }; + }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); + + // Wire to shared draw-on-image module when draw mode is active (Tutor Pro). + useEffect(() => { + if (!isDrawModeActive || !option?.image_url) { + return; + } + const img = imageRef.current; + const canvas = canvasRef.current; + const api = typeof window !== 'undefined' ? window.TutorDrawOnImage : undefined; + if (!img || !canvas || !api?.init) { + return; + } + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + const brushSize = api.DEFAULT_BRUSH_SIZE ?? 15; + const instance = api.init({ + image: img, + canvas, + brushSize, + strokeStyle: INSTRUCTOR_STROKE_STYLE, + initialMaskUrl: option.answer_two_gap_match || undefined, + }); + drawInstanceRef.current = instance; + return () => { + instance.destroy(); + drawInstanceRef.current = null; + }; + }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match]); + + // Cleanup shared instance on unmount. + useEffect(() => { + return () => { + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + }; + }, []); + + const handleSave = () => { + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const dataUrl = canvas.toDataURL('image/png'); + const blank = document.createElement('canvas'); + blank.width = canvas.width; + blank.height = canvas.height; + const isEmpty = dataUrl === blank.toDataURL(); + + const updated: QuizQuestionOption = { + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + answer_two_gap_match: isEmpty ? '' : dataUrl, + is_saved: true, + }; + updateOption(updated); + + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + setIsDrawModeActive(false); + }; + + const handleClear = () => { + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx?.clearRect(0, 0, canvas.width, canvas.height); + } + + const updated: QuizQuestionOption = { + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + answer_two_gap_match: '', + is_saved: true, + }; + updateOption(updated); + setIsDrawModeActive(false); + }; + + const handleDraw = () => { + setIsDrawModeActive(true); + }; + + const clearImage = () => { + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + setIsDrawModeActive(false); + + const updated: QuizQuestionOption = { + ...option, + ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + image_id: undefined, + image_url: '', + }; + + updateOption(updated); + resetFiles(); + + const canvas = canvasRef.current; + if (canvas) { + const ctx = canvas.getContext('2d'); + ctx?.clearRect(0, 0, canvas.width, canvas.height); + } + }; + + return ( +
+ {/* Section 1: Image upload only — one reference shown in Mark the correct area */} +
+
+ +
+
+ + {/* Section 2: Mark the correct area — single reference image + drawing canvas; Save / Clear / Draw buttons */} + +
+
+ + + + + {__('Mark the correct area', __TUTOR_TEXT_DOMAIN__)} + +
+
+ {__('Background + +
+
+ + + +
+

+ {__('Use the brush to draw on the image, then click Save to store the answer zone.', __TUTOR_TEXT_DOMAIN__)} +

+ +

+ {__('Answer zone saved. Students will be graded against this area.', __TUTOR_TEXT_DOMAIN__)} +

+
+
+
+ + +

+ {__( + 'Upload an image to define the area students must draw on. Then mark the correct zone in the next section.', + __TUTOR_TEXT_DOMAIN__, + )} +

+
+
+ ); +}; + +export default FormDrawImage; + +const styles = { + wrapper: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[24]}; + padding-left: ${spacing[40]}; + + ${Breakpoint.smallMobile} { + padding-left: ${spacing[8]}; + } + `, + card: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[16]}; + padding: ${spacing[20]}; + background: ${colorTokens.surface.tutor}; + border: 1px solid ${colorTokens.stroke.border}; + border-radius: ${borderRadius.card}; + `, + imageInputWrapper: css` + max-width: 100%; + `, + imageInput: css` + border-radius: ${borderRadius.card}; + `, + answerHeader: css` + ${styleUtils.display.flex('row')}; + align-items: center; + justify-content: space-between; + gap: ${spacing[12]}; + `, + answerHeaderTitle: css` + ${typography.body('medium')}; + color: ${colorTokens.text.primary}; + ${styleUtils.display.flex('row')}; + align-items: center; + gap: ${spacing[8]}; + `, + headerIcon: css` + flex-shrink: 0; + color: ${colorTokens.text.subdued}; + `, + canvasInner: css` + position: relative; + display: inline-block; + border-radius: ${borderRadius.card}; + overflow: hidden; + + img { + display: block; + max-width: 100%; + height: auto; + } + `, + image: css` + display: block; + max-width: 100%; + height: auto; + `, + answerImage: css` + filter: grayscale(0.15); + `, + canvas: css` + position: absolute; + top: 0; + left: 0; + `, + canvasIdleMode: css` + pointer-events: none; + cursor: default; + `, + canvasDrawMode: css` + pointer-events: auto; + cursor: crosshair; + `, + actionsRow: css` + ${styleUtils.display.flex('row')}; + gap: ${spacing[12]}; + flex-wrap: wrap; + `, + brushHint: css` + ${typography.caption()}; + color: ${colorTokens.text.subdued}; + margin: 0; + `, + savedHint: css` + ${typography.caption()}; + color: ${colorTokens.text.success}; + margin: 0; + `, + placeholder: css` + ${typography.caption()}; + color: ${colorTokens.text.subdued}; + `, +}; From 53131c71b79e61854b74e02f828b402667e863a3 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 5 Feb 2026 13:24:37 +0600 Subject: [PATCH 16/19] refactor: update TutorDrawOnImage type definitions and clean up FormDrawImage component - Added type definitions for the TutorDrawOnImage API to enhance type safety and integration with the drawing functionality. - Removed deprecated interface definitions from FormDrawImage component, streamlining the code and improving clarity. - Updated the FormDrawImage component to better utilize the shared draw-on-image API, enhancing overall code organization and maintainability. --- assets/src/js/v3/@types/index.d.ts | 12 +++++++++++ .../fields/quiz/questions/FormDrawImage.tsx | 20 ------------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/assets/src/js/v3/@types/index.d.ts b/assets/src/js/v3/@types/index.d.ts index 9a6db0451c..010bd964c7 100644 --- a/assets/src/js/v3/@types/index.d.ts +++ b/assets/src/js/v3/@types/index.d.ts @@ -52,6 +52,18 @@ declare global { // eslint-disable-next-line @typescript-eslint/no-explicit-any const wp: any; interface Window { + TutorDrawOnImage?: { + init: (options: { + image: HTMLImageElement; + canvas: HTMLCanvasElement; + brushSize?: number; + strokeStyle?: string; + initialMaskUrl?: string; + onMaskChange?: (value: string) => void; + }) => { destroy: () => void }; + DEFAULT_BRUSH_SIZE?: number; + DEFAULT_STROKE_STYLE?: string; + }; // eslint-disable-next-line @typescript-eslint/no-explicit-any wp: any; ajaxurl: string; diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index 2082eeaa9d..591b19b0ec 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -21,26 +21,6 @@ import { } from '@TutorShared/utils/types'; import { nanoid } from '@TutorShared/utils/util'; -/** Shared draw-on-image API from Tutor Pro (window.TutorDrawOnImage). */ -interface TutorDrawOnImageAPI { - init: (options: { - image: HTMLImageElement; - canvas: HTMLCanvasElement; - brushSize?: number; - strokeStyle?: string; - initialMaskUrl?: string; - onMaskChange?: (value: string) => void; - }) => { destroy: () => void }; - DEFAULT_BRUSH_SIZE?: number; - DEFAULT_STROKE_STYLE?: string; -} - -declare global { - interface Window { - TutorDrawOnImage?: TutorDrawOnImageAPI; - } -} - const INSTRUCTOR_STROKE_STYLE = 'rgba(255, 0, 0, 0.9)'; interface FormDrawImageProps extends FormControllerProps { From 3aec3dbbeb12b822a005b5e9f88e7270e06cbe2a Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 5 Feb 2026 13:42:08 +0600 Subject: [PATCH 17/19] refactor: enhance FormDrawImage component to manage image replacement and drawing state - Added logic to clear previous drawings when an image is replaced, ensuring the new image displays correctly without old masks. - Implemented cleanup for the drawing instance and canvas to improve user experience during image updates. - Updated comments for better clarity on the functionality related to image handling in the quiz question component. --- .../components/fields/quiz/questions/FormDrawImage.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index 591b19b0ec..ea6b64a5db 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -125,6 +125,7 @@ const FormDrawImage = ({ field, questionId }: FormDrawImageProps) => { onChange: (file) => { if (file && !Array.isArray(file)) { const { id, url } = file; + // Clear previous draw when image is replaced — the saved mask was for the old image. const updated: QuizQuestionOption = { ...option, ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { @@ -132,8 +133,15 @@ const FormDrawImage = ({ field, questionId }: FormDrawImageProps) => { }), image_id: id, image_url: url, + answer_two_gap_match: '', }; updateOption(updated); + // Clean up draw instance and canvas so the new image shows without the old mask. + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + setIsDrawModeActive(false); } }, initialFiles: option.image_id From 5798941a3ff5997b2b1bc14e82b4ff5bfd5776a3 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 6 Feb 2026 11:25:24 +0600 Subject: [PATCH 18/19] refactor: update question components to reflect answer save state - Changed the `is_saved` property from `true` to `false` in the QuestionList, DrawImage, and FormDrawImage components to ensure that new answers are marked as unsaved by default. - This adjustment improves the handling of answer states during quiz interactions, enhancing user experience and data management. --- .../course-builder/components/curriculum/QuestionList.tsx | 2 +- .../components/curriculum/question-types/DrawImage.tsx | 2 +- .../shared/components/fields/quiz/questions/FormDrawImage.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx index 09627afe13..ad4cc13653 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx @@ -218,7 +218,7 @@ const QuestionList = ({ isEditing }: { isEditing: boolean }) => { ? [ { _data_status: QuizDataStatus.NEW, - is_saved: true, + is_saved: false, answer_id: nanoid(), answer_title: '', belongs_question_id: questionId, diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx index c1f1caf134..cb957e7179 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx @@ -31,7 +31,7 @@ const DrawImage = () => { } const baseAnswer: QuizQuestionOption = { _data_status: QuizDataStatus.NEW, - is_saved: true, + is_saved: false, answer_id: nanoid(), belongs_question_id: activeQuestionId, belongs_question_type: 'draw_image' as QuizQuestionOption['belongs_question_type'], diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index ea6b64a5db..fbcb6293aa 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -39,7 +39,7 @@ interface FormDrawImageProps extends FormControllerProps ({ _data_status: QuizDataStatus.NEW, - is_saved: true, + is_saved: false, answer_id: nanoid(), belongs_question_id: questionId, belongs_question_type: 'draw_image' as QuizQuestionOption['belongs_question_type'], From 9f71fe3bbb30989435c88ce0a797ccb4c1f9a324 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 6 Feb 2026 12:26:22 +0600 Subject: [PATCH 19/19] refactor: enhance DrawImage and FormDrawImage components for improved state management - Updated the DrawImage component to conditionally render based on the presence of optionsFields, ensuring better handling of undefined values. - Simplified the FormDrawImage component by removing unnecessary default option logic and directly utilizing the field value. - Improved variable naming for clarity in the canvas dimensions, enhancing code readability. - Updated comments to provide clearer explanations of the component's functionality and lifecycle management. --- .../curriculum/question-types/DrawImage.tsx | 11 ++++- .../fields/quiz/questions/FormDrawImage.tsx | 42 ++++++++----------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx index cb957e7179..e69c57c6b0 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx @@ -46,15 +46,24 @@ const DrawImage = () => { form.setValue(answersPath, [baseAnswer]); }, [activeQuestionId, optionsFields.length, answersPath, form]); + // Only render Controller when the value exists to ensure field.value is always defined + if (optionsFields.length === 0) { + return null; + } + return (
( { +interface FormDrawImageProps extends FormControllerProps { questionId: ID; validationError?: { message: string; @@ -37,23 +36,8 @@ interface FormDrawImageProps extends FormControllerProps; } -const getDefaultOption = (questionId: ID): QuizQuestionOption => ({ - _data_status: QuizDataStatus.NEW, - is_saved: false, - answer_id: nanoid(), - belongs_question_id: questionId, - belongs_question_type: 'draw_image' as QuizQuestionOption['belongs_question_type'], - answer_title: '', - is_correct: '1', - image_id: undefined, - image_url: '', - answer_two_gap_match: '', - answer_view_format: 'draw_image', - answer_order: 0, -}); - -const FormDrawImage = ({ field, questionId }: FormDrawImageProps) => { - const option = (field.value ?? getDefaultOption(questionId)) as QuizQuestionOption; +const FormDrawImage = ({ field }: FormDrawImageProps) => { + const option = field.value; const [isDrawModeActive, setIsDrawModeActive] = useState(false); @@ -87,15 +71,15 @@ const FormDrawImage = ({ field, questionId }: FormDrawImageProps) => { } const rect = container.getBoundingClientRect(); - const w = Math.round(rect.width); - const h = Math.round(rect.height); + const width = Math.round(rect.width); + const height = Math.round(rect.height); - if (!w || !h) { + if (!width || !height) { return; } - canvas.width = w; - canvas.height = h; + canvas.width = width; + canvas.height = height; canvas.style.position = 'absolute'; canvas.style.top = '0'; canvas.style.left = '0'; @@ -153,7 +137,15 @@ const FormDrawImage = ({ field, questionId }: FormDrawImageProps) => { : null, }); - // Display-only sync when not in draw mode (saved mask + canvas size). + /* + * Display-only canvas sync (when not in draw mode): we use three separate useEffects + * so each one handles a single concern and its own cleanup: + * 1) Sync immediately when deps change (image URL, mask, draw mode). + * 2) Sync when the fires 'load' (e.g. after src change or first load). + * 3) Sync when the container is resized (ResizeObserver). + * React runs them in declaration order after commit; merging into one effect would + * mix three different triggers and cleanups (addEventListener, ResizeObserver) in one place. + */ useEffect(() => { if (isDrawModeActive) { return;