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/@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/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..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
@@ -103,6 +103,12 @@ const questionTypeOptions: {
icon: 'quizOrdering',
isPro: true,
},
+ {
+ label: __('Draw on Image', '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: false,
+ 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..e69c57c6b0
--- /dev/null
+++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx
@@ -0,0 +1,84 @@
+import { css } from '@emotion/react';
+import { useEffect } from 'react';
+import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
+
+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';
+
+const DrawImage = () => {
+ const form = useFormContext();
+ const { activeQuestionId, activeQuestionIndex, validationError, setValidationError } = useQuizModalContext();
+
+ const answersPath = `questions.${activeQuestionIndex}.question_answers` as 'questions.0.question_answers';
+
+ const { fields: optionsFields } = useFieldArray({
+ control: form.control,
+ name: answersPath,
+ });
+
+ // Ensure there is always a single option for this question type.
+ useEffect(() => {
+ if (!activeQuestionId) {
+ return;
+ }
+ if (optionsFields.length > 0) {
+ return;
+ }
+ const baseAnswer: QuizQuestionOption = {
+ _data_status: QuizDataStatus.NEW,
+ is_saved: false,
+ 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, 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 (
+
+ (
+
+ )}
+ />
+
+ );
+};
+
+export default DrawImage;
+
+const styles = {
+ optionWrapper: css`
+ ${styleUtils.display.flex('column')};
+ padding-left: ${spacing[40]};
+ `,
+};
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..8df0d84431
--- /dev/null
+++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx
@@ -0,0 +1,511 @@
+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';
+
+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 FormDrawImage = ({ field }: FormDrawImageProps) => {
+ const option = field.value;
+
+ 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 width = Math.round(rect.width);
+ const height = Math.round(rect.height);
+
+ if (!width || !height) {
+ return;
+ }
+
+ canvas.width = width;
+ canvas.height = height;
+ 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;
+ // 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) && {
+ _data_status: calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) as QuizDataStatus,
+ }),
+ 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
+ ? {
+ id: Number(option.image_id),
+ url: option.image_url || '',
+ title: option.image_url || '',
+ }
+ : null,
+ });
+
+ /*
+ * 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;
+ }
+ 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__)}
+
+
+
+

+
+
+
+ }
+ >
+ {__('Save', __TUTOR_TEXT_DOMAIN__)}
+
+ }
+ >
+ {__('Clear', __TUTOR_TEXT_DOMAIN__)}
+
+ }
+ >
+ {__('Draw', __TUTOR_TEXT_DOMAIN__)}
+
+
+
+ {__('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};
+ `,
+};
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..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.
@@ -736,6 +739,18 @@ 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 ) {
+ $given_answer = '';
+ if ( is_array( $answers ) && isset( $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 = QuizModel::save_quiz_draw_image_mask( $given_answer );
+ }
+
+ // Base correctness is determined later via filters in Tutor Pro.
+ $is_answer_was_correct = false;
}
$question_mark = $is_answer_was_correct ? $question->question_mark : 0;
@@ -766,6 +781,20 @@ 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 ) {
+ // 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 );
}
}
@@ -1079,9 +1108,27 @@ public function ajax_quiz_delete() {
do_action( 'tutor_delete_quiz_before', $quiz_id );
+ // 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 ) ) {
+ $attempt_ids = array_map(
+ function ( $row ) {
+ return (int) $row->attempt_id;
+ },
+ $attempts_for_quiz
+ );
+ $attempt_file_paths = QuizModel::get_attempt_file_paths_for_deletion( $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 ) ) {
@@ -1098,6 +1145,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/classes/QuizBuilder.php b/classes/QuizBuilder.php
index 5acd03a171..4272f5bbc7 100644
--- a/classes/QuizBuilder.php
+++ b/classes/QuizBuilder.php
@@ -89,7 +89,15 @@ 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 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(
+ 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'] ?? '', '' );
+ }
$answer_view_format = Input::sanitize( $input['answer_view_format'] ?? '' );
$answer_settings = null;
diff --git a/classes/Utils.php b/classes/Utils.php
index 08ac972d96..0bd72cfb61 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 ] ) ) {
@@ -10585,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] ) ) {
@@ -10606,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/CourseModel.php b/models/CourseModel.php
index 062b10920b..186b89c42e 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 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 ) ) {
+ $attempt_ids = array_map(
+ function ( $row ) {
+ return (int) $row->attempt_id;
+ },
+ $attempts_for_quiz
+ );
+ $attempt_file_paths = QuizModel::get_attempt_file_paths_for_deletion( $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 b8c13cdb40..a2a94fc95e 100644
--- a/models/QuizModel.php
+++ b/models/QuizModel.php
@@ -545,22 +545,270 @@ 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 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(
+ 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 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 by get_attempt_file_paths_for_deletion; also usable when only draw_image paths are needed.
+ *
+ * @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_ids = array_filter( array_map( 'absint', $attempt_ids ) );
+ if ( empty( $attempt_ids ) ) {
+ return $paths;
+ }
+
+ $response = QueryHelper::get_joined_data(
+ 'tutor_quiz_attempt_answers AS a',
+ array(
+ array(
+ 'type' => 'INNER',
+ 'table' => 'tutor_quiz_questions AS q',
+ 'on' => 'a.question_id = q.question_id',
+ ),
+ ),
+ array( 'a.given_answer' ),
+ array(
+ 'a.quiz_attempt_id' => $attempt_ids,
+ 'q.question_type' => 'draw_image',
+ ),
+ array(),
+ '',
+ -1,
+ 0
+ );
+
+ $rows = isset( $response['results'] ) && is_array( $response['results'] ) ? $response['results'] : array();
+ if ( empty( $rows ) ) {
+ return $paths;
+ }
+
+ return self::resolve_draw_image_urls_to_paths( $rows, 'given_answer', $uploads_base_url, $uploads_base_dir, $quiz_image_url );
+ }
+
+ /**
+ * 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 );
+ }
+ }
+ }
+
+ /**
+ * 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).
+ *
+ * @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;
+ }
- //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
+ $uploads_base_url = trailingslashit( $upload_dir['baseurl'] );
+ $uploads_base_dir = trailingslashit( $upload_dir['basedir'] );
+ $quiz_image_url = $uploads_base_url . 'tutor/quiz-image/';
+
+ $draw_image_questions = QueryHelper::get_all(
+ 'tutor_quiz_questions',
+ array(
+ 'quiz_id' => $quiz_id,
+ 'question_type' => 'draw_image',
+ ),
+ 'question_id',
+ -1
+ );
- do_action( 'tutor_quiz/attempt_deleted', $attempt_ids );
+ if ( empty( $draw_image_questions ) ) {
+ return $paths;
}
+
+ $question_ids = array_map(
+ function ( $row ) {
+ return (int) $row->question_id;
+ },
+ $draw_image_questions
+ );
+
+ $question_answers = QueryHelper::get_all(
+ 'tutor_quiz_question_answers',
+ array(
+ 'belongs_question_id' => $question_ids,
+ 'belongs_question_type' => 'draw_image',
+ ),
+ 'answer_id',
+ -1
+ );
+
+ return self::resolve_draw_image_urls_to_paths( $question_answers, 'answer_two_gap_match', $uploads_base_url, $uploads_base_dir, $quiz_image_url );
+ }
+
+ /**
+ * 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 );
}
/**
@@ -1131,10 +1379,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
*
@@ -1310,4 +1588,65 @@ public static function update_attempt_result( $attempt_id ) {
return false;
}
+
+ /**
+ * Save draw-image question mask (base64 or URL) to uploads and return file 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 4.0.0
+ *
+ * @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 static 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;
+ }
+
+ // 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. Save to tutor/quiz-image (not wp media).
+ if ( ! preg_match( '#^data:image/(\w+);base64,(.+)$#is', $value, $m ) ) {
+ return $value;
+ }
+
+ $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 );
+ }
+ }
}
diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php
index f17e54bb42..d6a3a240d7 100644
--- a/views/quiz/attempt-details.php
+++ b/views/quiz/attempt-details.php
@@ -354,6 +354,11 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an
elseif ( in_array( $answer->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,30 @@ 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: 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 ) && 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 );
+ $instructor_answer = is_array( $draw_image_answers ) && ! empty( $draw_image_answers ) ? reset( $draw_image_answers ) : null;
+ $given_bg_url = QuizModel::get_answer_image_url( $instructor_answer );
+ 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 +724,32 @@ function( $ans ) {
}
echo '';
}
+
+ // 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 ) && false !== wp_http_validate_url( $ref_mask );
+
+ if ( $instructor_answer && $ref_mask_is_url ) {
+ $ref_bg = QuizModel::get_answer_image_url( $instructor_answer );
+ 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' ) . '';
+ }
+ }
}
?>