diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index 2c3724d65f..f3d17bcf83 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', 'draw_image']; + const revealModeSupportedQuestions = ['true_false', 'single_choice', 'multiple_choice']; let quiz_options = _tutorobject.quiz_options let interactions = new Map(); @@ -102,13 +102,6 @@ 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; } @@ -167,14 +160,7 @@ window.jQuery(document).ready($ => { var $inputs = $required_answer_wrap.find('input'); if ($inputs.length) { var $type = $inputs.attr('type'); - // 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 ($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; @@ -233,12 +219,6 @@ 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.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx index b2846d5eae..44f62af47f 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 @@ -40,6 +40,7 @@ const questionTypeIconMap: Record { image_answering: , ordering: , draw_image: , + pin_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 f282e90b25..3f5194fc74 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 @@ -106,6 +106,14 @@ const questionTypeOptions: { { label: __('Draw on Image', 'tutor'), value: 'draw_image', + // TODO: icon is not final. + icon: 'quizImageAnswer', + isPro: true, + }, + { + label: __('Pin on Image', 'tutor'), + value: 'pin_image', + // TODO: icon is not final. icon: 'quizImageAnswer', isPro: true, }, @@ -116,7 +124,7 @@ const isTutorPro = !!tutorConfig.tutor_pro_url; const QuestionList = ({ isEditing }: { isEditing: boolean }) => { const questionTypeOptionsForUi = useMemo(() => { if (tutorConfig.is_legacy_learning_mode) { - return questionTypeOptions.filter((option) => option.value !== 'draw_image'); + return questionTypeOptions.filter((option) => option.value !== 'draw_image' && option.value !== 'pin_image'); } return questionTypeOptions; }, []); @@ -235,7 +243,22 @@ const QuestionList = ({ isEditing }: { isEditing: boolean }) => { is_correct: '1', }, ] - : [], + : questionType === 'pin_image' + ? [ + { + _data_status: QuizDataStatus.NEW, + is_saved: true, + answer_id: nanoid(), + answer_title: '', + belongs_question_id: questionId, + belongs_question_type: 'pin_image', + answer_two_gap_match: '', + answer_view_format: 'pin_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/PinImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/PinImage.tsx new file mode 100644 index 0000000000..2bc91ba5f4 --- /dev/null +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/PinImage.tsx @@ -0,0 +1,80 @@ +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 FormPinImage from '@TutorShared/components/fields/quiz/questions/FormPinImage'; +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 PinImage = () => { + 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: 'pin_image' as QuizQuestionOption['belongs_question_type'], + answer_title: '', + is_correct: '1', + image_id: undefined, + image_url: '', + answer_two_gap_match: '', + answer_view_format: 'pin_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 PinImage; + +const styles = { + optionWrapper: css` + ${styleUtils.display.flex('column')}; + padding-left: ${spacing[40]}; + `, +}; diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx new file mode 100644 index 0000000000..7dbea0fa44 --- /dev/null +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx @@ -0,0 +1,667 @@ +import { css } from '@emotion/react'; +import { __ } from '@wordpress/i18n'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +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 LASSO_FILL_STYLE = 'rgba(220, 53, 69, 0.45)'; +const LASSO_STROKE_STYLE = 'rgba(220, 53, 69, 0.95)'; +const LASSO_DASH_PATTERN = [8, 6]; +const LASSO_MIN_POINT_DISTANCE = 4; + +interface FormPinImageProps extends FormControllerProps { + questionId: ID; + validationError?: { + message: string; + type: QuizValidationErrorType; + } | null; + setValidationError?: React.Dispatch< + React.SetStateAction<{ + message: string; + type: QuizValidationErrorType; + } | null> + >; +} + +const FormPinImage = ({ field }: FormPinImageProps) => { + 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 isLassoDrawingRef = useRef(false); + const lassoPointsRef = useRef>([]); + const baseImageDataRef = useRef(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) && option) { + 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 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]); + + // Pin image uses lasso-style polygon drawing for marking the valid pin zone. + useEffect(() => { + if (!isDrawModeActive || !option?.image_url) { + return; + } + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + if (drawInstanceRef.current) { + drawInstanceRef.current.destroy(); + drawInstanceRef.current = null; + } + + const getPointFromEvent = (event: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const x = (event.clientX - rect.left) * scaleX; + const y = (event.clientY - rect.top) * scaleY; + return { + x: Math.max(0, Math.min(canvas.width, x)), + y: Math.max(0, Math.min(canvas.height, y)), + }; + }; + + const renderPathPreview = () => { + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + const points = lassoPointsRef.current; + if (!baseImageDataRef.current || points.length < 2) { + return; + } + + ctx.putImageData(baseImageDataRef.current, 0, 0); + ctx.beginPath(); + ctx.moveTo(points[0]?.x || 0, points[0]?.y || 0); + points.forEach((point, index) => { + if (index > 0) { + ctx.lineTo(point.x, point.y); + } + }); + ctx.lineTo(points[0]?.x || 0, points[0]?.y || 0); + ctx.closePath(); + + ctx.fillStyle = LASSO_FILL_STYLE; + ctx.fill(); + + ctx.setLineDash(LASSO_DASH_PATTERN); + ctx.lineWidth = 2; + ctx.strokeStyle = LASSO_STROKE_STYLE; + ctx.stroke(); + ctx.setLineDash([]); + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) { + return; + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + canvas.setPointerCapture(event.pointerId); + isLassoDrawingRef.current = true; + lassoPointsRef.current = [getPointFromEvent(event)]; + baseImageDataRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!isLassoDrawingRef.current) { + return; + } + const nextPoint = getPointFromEvent(event); + const points = lassoPointsRef.current; + const lastPoint = points[points.length - 1]; + if (!lastPoint) { + points.push(nextPoint); + renderPathPreview(); + return; + } + const dx = nextPoint.x - lastPoint.x; + const dy = nextPoint.y - lastPoint.y; + if (Math.hypot(dx, dy) < LASSO_MIN_POINT_DISTANCE) { + return; + } + points.push(nextPoint); + renderPathPreview(); + }; + + const finishLasso = () => { + if (!isLassoDrawingRef.current) { + return; + } + isLassoDrawingRef.current = false; + const points = lassoPointsRef.current; + if (points.length >= 3) { + renderPathPreview(); + } else if (baseImageDataRef.current) { + const ctx = canvas.getContext('2d'); + ctx?.putImageData(baseImageDataRef.current, 0, 0); + } + lassoPointsRef.current = []; + baseImageDataRef.current = null; + }; + + const onPointerUp = (event: PointerEvent) => { + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + finishLasso(); + }; + + const onPointerCancel = (event: PointerEvent) => { + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + finishLasso(); + }; + + canvas.addEventListener('pointerdown', onPointerDown); + canvas.addEventListener('pointermove', onPointerMove); + canvas.addEventListener('pointerup', onPointerUp); + canvas.addEventListener('pointercancel', onPointerCancel); + + const instance = { + destroy: () => { + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerCancel); + isLassoDrawingRef.current = false; + lassoPointsRef.current = []; + baseImageDataRef.current = null; + }, + }; + 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 persistCanvasMask = useCallback(() => { + 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, + }; + updateOption(updated); + }, [option, updateOption]); + + const handleClear = () => { + if (!option) { + return; + } + + 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); + }; + + const handleCanvasMouseEnter = () => { + setIsDrawModeActive(true); + }; + + const handleCanvasMouseLeave = () => { + if (!isLassoDrawingRef.current) { + setIsDrawModeActive(false); + } + }; + + 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, + }), + 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); + } + }; + + useEffect(() => { + if (!isDrawModeActive || !option?.image_url) { + return; + } + const canvas = canvasRef.current; + if (!canvas) { + return; + } + + const onPointerUp = () => { + persistCanvasMask(); + }; + + canvas.addEventListener('pointerup', onPointerUp); + canvas.addEventListener('pointercancel', onPointerUp); + + return () => { + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerUp); + }; + }, [isDrawModeActive, option?.image_url, persistCanvasMask]); + + if (!option) { + return null; + } + + return ( +
+ {/* Section 1: Image upload only — one reference shown for pin-area quizzes */} +
+
+ +
+
+ + {/* Section 2: Mark the valid pin area — drawing auto-enables on image hover */} + +
+
+ + + + + {__('Mark the correct area', __TUTOR_TEXT_DOMAIN__)} + +
+ +
+
+
+ {__('Background + +
+ +

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

+
+
+
+ + +

+ {__( + 'Upload an image to define where students must drop a pin. Then mark the valid area in the next section.', + __TUTOR_TEXT_DOMAIN__, + )} +

+
+
+ ); +}; + +export default FormPinImage; + +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-radius: ${borderRadius.card}; + `, + imageInputWrapper: css` + max-width: 100%; + `, + imageInputEmpty: css` + border-radius: ${borderRadius.card}; + `, + imageInputPreview: css` + width: fit-content; + max-width: 100%; + height: auto; + border-radius: ${borderRadius.card}; + + img { + width: auto; + max-width: 100%; + height: auto; + object-fit: initial; + } + `, + 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.1); + `, + canvas: css` + position: absolute; + top: 0; + left: 0; + z-index: 1; + `, + 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; + color: ${colorTokens.text.brand}; + `, + clearButton: css` + width: 94px; + border: none; + border-radius: ${borderRadius.input}; + background: ${colorTokens.action.secondary.default}; + ${typography.caption('medium')}; + color: ${colorTokens.text.brand}; + display: flex; + justify-content: center; + align-items: center; + gap: ${spacing[8]}; + padding: ${spacing[4]} 0; + cursor: pointer; + `, + clearButtonIcon: css` + color: ${colorTokens.text.brand}; + `, + 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 393ef15756..2b84dc0b0a 100644 --- a/assets/src/js/v3/shared/utils/types.ts +++ b/assets/src/js/v3/shared/utils/types.ts @@ -296,6 +296,7 @@ export type QuizQuestionType = | 'image_answering' | 'ordering' | 'draw_image' + | 'pin_image' | 'h5p'; export interface QuizQuestionOption { diff --git a/classes/Quiz.php b/classes/Quiz.php index 29796ef77a..721a52ad38 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -196,7 +196,9 @@ public function skip_addon_only_question_partials( $load, $template, $variables 'tutor_quiz_templates_not_in_core', array( 'learning-area.quiz.questions.draw-image', + 'learning-area.quiz.questions.pin-image', 'shared.components.quiz.attempt-details.questions.draw-image', + 'shared.components.quiz.attempt-details.questions.pin-image', ), $template, $variables @@ -1293,7 +1295,7 @@ function ( $row ) { QuizModel::delete_files_by_paths( $attempt_file_paths ); - // Collect instructor file paths before deleting question data (e.g. draw_image masks). + // Collect instructor file paths before deleting question data (e.g. draw_image / pin_image masks). /** * Filter to get file paths for quiz deletion. * Pro and other add-ons register their question types via this filter. diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php index e4234388b9..aa808b5feb 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -89,7 +89,7 @@ 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 ); - // Let the hook handle special cases (e.g. draw_image) and return a normalized value. + // Let the hook handle special cases (e.g. draw_image, pin_image) and return a normalized value (URL). $answer_two_gap_match_raw = isset( $input['answer_two_gap_match'] ) ? wp_unslash( $input['answer_two_gap_match'] ) : ''; $answer_two_gap_match_raw = apply_filters( 'tutor_save_quiz_draw_image_mask', $answer_two_gap_match_raw, $question_type ); $answer_two_gap_match = Input::sanitize( $answer_two_gap_match_raw ?? '', '' ); @@ -191,6 +191,10 @@ public function save_questions( $quiz_id, $questions ) { $legacy_draw_image_message = __( 'Draw on Image questions are not available when Legacy learning mode is enabled.', 'tutor' ); throw new \Exception( $legacy_draw_image_message ); } + if ( 'pin_image' === $question_type && tutor_utils()->is_legacy_learning_mode() ) { + $legacy_pin_image_message = __( 'Pin on Image questions are not available when Legacy learning mode is enabled.', 'tutor' ); + throw new \Exception( $legacy_pin_image_message ); + } $question_data = $this->prepare_question_data( $quiz_id, $question ); $question_answers = isset( $question['question_answers'] ) ? $question['question_answers'] : array(); diff --git a/classes/Utils.php b/classes/Utils.php index 1504169ea7..1e48e89ecb 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -5293,6 +5293,11 @@ public function get_question_types( $type = null ) { 'icon' => '', 'is_pro' => true, ), + 'pin_image' => array( + 'name' => __( 'Pin on Image', 'tutor' ), + 'icon' => '', + 'is_pro' => true, + ), ); if ( isset( $types[ $type ] ) ) { diff --git a/models/CourseModel.php b/models/CourseModel.php index cfcfbabe07..d2e026de40 100644 --- a/models/CourseModel.php +++ b/models/CourseModel.php @@ -549,7 +549,7 @@ function ( $row ) { do_action( 'tutor_before_delete_quiz_content', $content_id, null ); - // Collect instructor file paths before deleting question data (e.g. draw_image masks). + // Collect instructor file paths before deleting question data (e.g. draw_image / pin_image masks). /** * Filter to get file paths for quiz deletion. * Pro and other add-ons register their question types via this filter. diff --git a/templates/shared/components/quiz/attempt-details/review-answers.php b/templates/shared/components/quiz/attempt-details/review-answers.php index 0469a38091..5523d42526 100644 --- a/templates/shared/components/quiz/attempt-details/review-answers.php +++ b/templates/shared/components/quiz/attempt-details/review-answers.php @@ -50,6 +50,8 @@ $is_draw_image_review = 'draw_image' === $question_type; + $is_pin_review = 'pin_image' === $question_type; + $attempt_answer = $attempt_answers_map[ $question_id ] ?? null; $question_template = ''; @@ -64,6 +66,8 @@ $question_template = 'open-ended'; } elseif ( $is_fib_review ) { $question_template = 'fill-in-the-blank'; + } elseif ( $is_pin_review ) { + $question_template = 'pin-image'; } elseif ( $is_draw_image_review ) { $question_template = 'draw-image'; } diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php index 0cc4205300..776d7f24eb 100644 --- a/views/quiz/attempt-details.php +++ b/views/quiz/attempt-details.php @@ -544,7 +544,7 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an } else { /** * Allow Pro and add-ons to render given answer for custom question types. - * Pro handles draw_image via this action. + * Pro handles draw_image and pin_image via this action. * * @param object $answer Answer object. */ @@ -722,7 +722,7 @@ function( $ans ) { else { /** * Allow Pro and add-ons to render correct answer for custom question types. - * Pro handles draw_image via this action. + * Pro handles draw_image and pin_image via this action. * * @param object $answer Answer object. */