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__)} + +
+
+ {__('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}; + `, +}; 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' ) . ''; + } + } } ?>