From c6524d4bd67cdeeed1ca204369dfd403497095b7 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Fri, 6 Feb 2026 11:20:05 +0600
Subject: [PATCH 01/15] feat: add pin_image question type and enhance quiz
functionality
- Introduced a new question type 'pin_image' to the quiz system, allowing students to drop a pin on an image as an answer.
- Updated the quiz handling logic to support validation and processing of pin coordinates.
- Enhanced the frontend components to include the new pin_image type, ensuring proper rendering and interaction.
- Improved backend handling for pin_image answers, including file path management and answer validation.
- Updated relevant views to display pin submissions and instructor references correctly.
---
assets/src/js/front/course/_spotlight-quiz.js | 17 +-
.../components/curriculum/Question.tsx | 1 +
.../curriculum/QuestionConditions.tsx | 4 +
.../components/curriculum/QuestionForm.tsx | 2 +
.../components/curriculum/QuestionList.tsx | 23 +-
.../curriculum/question-types/PinImage.tsx | 75 +++
.../fields/quiz/questions/FormPinImage.tsx | 522 ++++++++++++++++++
assets/src/js/v3/shared/utils/types.ts | 1 +
classes/Quiz.php | 42 +-
classes/QuizBuilder.php | 4 +-
models/CourseModel.php | 7 +-
models/QuizModel.php | 63 +++
views/quiz/attempt-details.php | 64 +++
13 files changed, 812 insertions(+), 13 deletions(-)
create mode 100644 assets/src/js/v3/entries/course-builder/components/curriculum/question-types/PinImage.tsx
create mode 100644 assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx
diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js
index 2c3724d65f..428cfc6dcb 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', 'draw_image', 'pin_image'];
let quiz_options = _tutorobject.quiz_options
let interactions = new Map();
@@ -102,8 +102,8 @@ 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') {
+ // Reveal mode for draw_image & pin_image: show reference (instructor mask) and explanation.
+ if (is_reveal_mode() && ['draw_image', 'pin_image'].includes($question_wrap.data('question-type'))) {
$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;
@@ -174,6 +174,17 @@ window.jQuery(document).ready($ => {
$question_wrap.find('.answer-help-block').html(`${__('Please draw on the image to answer this question.', 'tutor')}
`);
validated = false;
}
+ } else if ($question_wrap.data('question-type') === 'pin_image') {
+ // Pin image: require normalized pin coordinates (hidden inputs [answers][pin][x|y]).
+ var $pinX = $required_answer_wrap.find('input[name*="[answers][pin][x]"]');
+ var $pinY = $required_answer_wrap.find('input[name*="[answers][pin][y]"]');
+ if (
+ !$pinX.length || !$pinY.length ||
+ !$pinX.val().trim().length || !$pinY.val().trim().length
+ ) {
+ $question_wrap.find('.answer-help-block').html(`${__('Please drop a pin 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')}
`);
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 09627afe13..1e8bc11480 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
@@ -109,6 +109,12 @@ const questionTypeOptions: {
icon: 'quizImageAnswer',
isPro: true,
},
+ {
+ label: __('Pin on Image', 'tutor'),
+ value: 'pin_image',
+ icon: 'quizImageAnswer',
+ isPro: true,
+ },
];
const isTutorPro = !!tutorConfig.tutor_pro_url;
@@ -229,7 +235,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..48bbc8b355
--- /dev/null
+++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/PinImage.tsx
@@ -0,0 +1,75 @@
+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: true,
+ 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]);
+
+ 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..5af6b91f39
--- /dev/null
+++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx
@@ -0,0 +1,522 @@
+import { css } from '@emotion/react';
+import { __ } from '@wordpress/i18n';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import Button from '@TutorShared/atoms/Button';
+import ImageInput from '@TutorShared/atoms/ImageInput';
+import SVGIcon from '@TutorShared/atoms/SVGIcon';
+
+import { borderRadius, Breakpoint, colorTokens, spacing } from '@TutorShared/config/styles';
+import { typography } from '@TutorShared/config/typography';
+import Show from '@TutorShared/controls/Show';
+import useWPMedia from '@TutorShared/hooks/useWpMedia';
+import type { FormControllerProps } from '@TutorShared/utils/form';
+import { calculateQuizDataStatus } from '@TutorShared/utils/quiz';
+import { styleUtils } from '@TutorShared/utils/style-utils';
+import {
+ type ID,
+ QuizDataStatus,
+ type QuizQuestionOption,
+ type QuizValidationErrorType,
+} from '@TutorShared/utils/types';
+import { nanoid } from '@TutorShared/utils/util';
+
+const INSTRUCTOR_STROKE_STYLE = 'rgba(0, 120, 255, 0.9)';
+
+interface FormPinImageProps extends FormControllerProps {
+ questionId: ID;
+ validationError?: {
+ message: string;
+ type: QuizValidationErrorType;
+ } | null;
+ setValidationError?: React.Dispatch<
+ React.SetStateAction<{
+ message: string;
+ type: QuizValidationErrorType;
+ } | null>
+ >;
+}
+
+const getDefaultOption = (questionId: ID): QuizQuestionOption => ({
+ _data_status: QuizDataStatus.NEW,
+ is_saved: true,
+ answer_id: nanoid(),
+ belongs_question_id: questionId,
+ belongs_question_type: '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,
+});
+
+const FormPinImage = ({ field, questionId }: FormPinImageProps) => {
+ const option = (field.value ?? getDefaultOption(questionId)) as QuizQuestionOption;
+
+ const [isDrawModeActive, setIsDrawModeActive] = useState(false);
+
+ const imageRef = useRef(null);
+ const canvasRef = useRef(null);
+ const drawInstanceRef = useRef<{ destroy: () => void } | null>(null);
+
+ const updateOption = useCallback(
+ (updated: QuizQuestionOption) => {
+ field.onChange(updated);
+ },
+ [field],
+ );
+
+ /** Display-only: sync canvas size and draw saved mask when not in draw mode. */
+ const syncCanvasDisplay = useCallback((maskUrl?: string) => {
+ const img = imageRef.current;
+ const canvas = canvasRef.current;
+
+ if (!img || !canvas) {
+ return;
+ }
+
+ if (!img.complete) {
+ return;
+ }
+
+ const container = img.parentElement;
+ if (!container) {
+ return;
+ }
+
+ const rect = container.getBoundingClientRect();
+ const w = Math.round(rect.width);
+ const h = Math.round(rect.height);
+
+ if (!w || !h) {
+ return;
+ }
+
+ canvas.width = w;
+ canvas.height = h;
+ canvas.style.position = 'absolute';
+ canvas.style.top = '0';
+ canvas.style.left = '0';
+ canvas.style.width = '100%';
+ canvas.style.height = '100%';
+
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ return;
+ }
+
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+
+ if (maskUrl) {
+ const maskImg = new Image();
+ maskImg.onload = () => {
+ ctx.drawImage(maskImg, 0, 0, canvas.width, canvas.height);
+ };
+ maskImg.src = maskUrl;
+ }
+ }, []);
+
+ const { openMediaLibrary, resetFiles } = useWPMedia({
+ options: {
+ type: 'image',
+ },
+ onChange: (file) => {
+ if (file && !Array.isArray(file)) {
+ const { id, url } = file;
+ // 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]);
+
+ // 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 for pin-area quizzes */}
+
+
+ {/* Section 2: Mark the valid pin area — single reference image + drawing canvas; Save / Clear / Draw buttons */}
+
+
+
+
+
+
+
+ {__('Mark the valid pin area', __TUTOR_TEXT_DOMAIN__)}
+
+
+
+
+
+
+
+ }
+ >
+ {__('Save', __TUTOR_TEXT_DOMAIN__)}
+
+ }
+ >
+ {__('Clear', __TUTOR_TEXT_DOMAIN__)}
+
+ }
+ >
+ {__('Draw', __TUTOR_TEXT_DOMAIN__)}
+
+
+
+ {__(
+ 'Use the brush to draw on the image where a pin should be considered correct, then click Save.',
+ __TUTOR_TEXT_DOMAIN__,
+ )}
+
+
+
+ {__('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: 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.1);
+ `,
+ 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 aa117bdad6..097bd9db2c 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 022aa3cbd6..744d4aa99f 100644
--- a/classes/Quiz.php
+++ b/classes/Quiz.php
@@ -751,6 +751,31 @@ function ( $ans ) {
// Base correctness is determined later via filters in Tutor Pro.
$is_answer_was_correct = false;
+ } elseif ( 'pin_image' === $question_type ) {
+ $given_answer = '';
+
+ // For pin_image, student drops a pin (map-like) on the image.
+ // Frontend posts normalized coordinates in:
+ // attempt[attempt_id][quiz_question][question_id][answers][pin][x|y]
+ if ( is_array( $answers ) && isset( $answers['answers']['pin'] ) && is_array( $answers['answers']['pin'] ) ) {
+ $raw_pin = $answers['answers']['pin'];
+ $x = isset( $raw_pin['x'] ) ? (float) $raw_pin['x'] : 0.0;
+ $y = isset( $raw_pin['y'] ) ? (float) $raw_pin['y'] : 0.0;
+
+ // Clamp to [0, 1] to avoid out-of-bounds data.
+ $x = max( 0.0, min( 1.0, $x ) );
+ $y = max( 0.0, min( 1.0, $y ) );
+
+ $given_answer = wp_json_encode(
+ array(
+ 'x' => $x,
+ 'y' => $y,
+ )
+ );
+ }
+
+ // Base correctness is determined later via filters (Tutor Pro).
+ $is_answer_was_correct = false;
}
$question_mark = $is_answer_was_correct ? $question->question_mark : 0;
@@ -785,11 +810,15 @@ function ( $ans ) {
$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
+ if ( 'pin_image' === $question_type ) {
+ $answers_data = apply_filters( 'tutor_filter_pin_image_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
+ }
+
+ // For Pro-powered draw-image and pin-image questions, adjust total marks after
// add-ons have had a chance to modify achieved_mark via filters.
- if ( 'draw_image' === $question_type ) {
+ if ( in_array( $question_type, array( 'draw_image', 'pin_image' ), true ) ) {
// Remove the previously added base question_mark (typically 0
- // for draw_image in core) and add the final achieved_mark
+ // for these question types 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'];
@@ -1126,8 +1155,11 @@ function ( $row ) {
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 );
+ // Collect instructor draw_image & pin_image file paths before deleting question data.
+ $quiz_file_paths = array_merge(
+ QuizModel::get_draw_image_file_paths_for_quiz( $quiz_id ),
+ QuizModel::get_pin_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 ) );
diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php
index 4272f5bbc7..d8d17aa8d8 100644
--- a/classes/QuizBuilder.php
+++ b/classes/QuizBuilder.php
@@ -89,9 +89,9 @@ public function prepare_answer_data( $question_id, $question_type, $input ) {
$answer_title = Input::sanitize( wp_slash( $input['answer_title'] ) ?? '', '' );
$is_correct = Input::sanitize( $input['is_correct'] ?? 0, 0, Input::TYPE_INT );
$image_id = Input::sanitize( $input['image_id'] ?? null );
- // Draw image: pass raw base64 or URL to QuizModel::save_quiz_draw_image_mask (Input::sanitize would corrupt base64
+ // Draw image / Pin 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'] ) ) {
+ if ( in_array( $question_type, array( 'draw_image', 'pin_image' ), true ) && 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'] ) )
);
diff --git a/models/CourseModel.php b/models/CourseModel.php
index 186b89c42e..9413df3606 100644
--- a/models/CourseModel.php
+++ b/models/CourseModel.php
@@ -545,8 +545,11 @@ function ( $row ) {
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 );
+ // Collect instructor draw_image & pin_image file paths before deleting question data.
+ $quiz_file_paths = array_merge(
+ QuizModel::get_draw_image_file_paths_for_quiz( $content_id ),
+ QuizModel::get_pin_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 ) ) {
diff --git a/models/QuizModel.php b/models/QuizModel.php
index a2a94fc95e..e5dde9bca9 100644
--- a/models/QuizModel.php
+++ b/models/QuizModel.php
@@ -793,6 +793,69 @@ function ( $row ) {
return self::resolve_draw_image_urls_to_paths( $question_answers, 'answer_two_gap_match', $uploads_base_url, $uploads_base_dir, $quiz_image_url );
}
+ /**
+ * Get file paths of pin_image instructor mask files for a quiz.
+ *
+ * Pin-image questions reuse the same mask storage (uploads/tutor/quiz-image)
+ * as draw_image; this helper mirrors get_draw_image_file_paths_for_quiz()
+ * but filters on question_type/belongs_question_type = pin_image.
+ *
+ * @since 4.0.0
+ *
+ * @param int $quiz_id Quiz post ID.
+ *
+ * @return string[] Array of absolute file paths.
+ */
+ public static function get_pin_image_file_paths_for_quiz( $quiz_id ) {
+ $paths = array();
+ $quiz_id = (int) $quiz_id;
+ if ( $quiz_id <= 0 ) {
+ return $paths;
+ }
+
+ $upload_dir = wp_upload_dir();
+ if ( ! empty( $upload_dir['error'] ) ) {
+ return $paths;
+ }
+
+ $uploads_base_url = trailingslashit( $upload_dir['baseurl'] );
+ $uploads_base_dir = trailingslashit( $upload_dir['basedir'] );
+ $quiz_image_url = $uploads_base_url . 'tutor/quiz-image/';
+
+ $pin_image_questions = QueryHelper::get_all(
+ 'tutor_quiz_questions',
+ array(
+ 'quiz_id' => $quiz_id,
+ 'question_type' => 'pin_image',
+ ),
+ 'question_id',
+ -1
+ );
+
+ if ( empty( $pin_image_questions ) ) {
+ return $paths;
+ }
+
+ $question_ids = array_map(
+ function ( $row ) {
+ return (int) $row->question_id;
+ },
+ $pin_image_questions
+ );
+
+ $question_answers = QueryHelper::get_all(
+ 'tutor_quiz_question_answers',
+ array(
+ 'belongs_question_id' => $question_ids,
+ 'belongs_question_type' => 'pin_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.
*
diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php
index d6a3a240d7..f5cfe168cf 100644
--- a/views/quiz/attempt-details.php
+++ b/views/quiz/attempt-details.php
@@ -555,6 +555,45 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an
} else {
echo '' . esc_html__( 'No drawing submitted.', 'tutor' ) . ' ';
}
+ } elseif ( 'pin_image' === $answer->question_type ) {
+
+ // Student's submitted pin: normalized coordinates stored as JSON in given_answer.
+ $coords_json = ! empty( $answer->given_answer ) ? stripslashes( $answer->given_answer ) : '';
+ $coords = json_decode( $coords_json, true );
+
+ if ( is_array( $coords ) && isset( $coords['x'], $coords['y'] ) ) {
+ $pin_x = (float) $coords['x'];
+ $pin_y = (float) $coords['y'];
+
+ // Load instructor background image to render the pin over it.
+ $pin_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, 'pin_image' );
+ $instructor_answer = is_array( $pin_answers ) && ! empty( $pin_answers ) ? reset( $pin_answers ) : null;
+ $bg_url = $instructor_answer ? QuizModel::get_answer_image_url( $instructor_answer ) : '';
+
+ if ( $bg_url ) {
+ echo '';
+ echo '
' . esc_html__( 'Your pin:', 'tutor' ) . '
';
+ echo '
';
+ echo '
';
+ echo '
';
+ echo '
';
+ echo '
';
+ } else {
+ echo '' . esc_html__( 'Pin submitted, but no background image found.', 'tutor' ) . ' ';
+ }
+ } else {
+ echo '' . esc_html__( 'No pin submitted.', 'tutor' ) . ' ';
+ }
}
?>
@@ -750,6 +789,31 @@ function( $ans ) {
echo '' . esc_html__( 'No reference available.', 'tutor' ) . ' ';
}
}
+ // Pin image: show instructor reference mask over background.
+ elseif ( 'pin_image' === $answer->question_type ) {
+
+ $pin_answers = QuizModel::get_answers_by_quiz_question( $answer->question_id, 'pin_image' );
+ $instructor_answer = is_array( $pin_answers ) && ! empty( $pin_answers ) ? reset( $pin_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 zone):', 'tutor' ) . '
';
+ if ( $ref_bg ) {
+ echo '
';
+ echo '
';
+ echo '
';
+ echo '
';
+ } else {
+ echo '
';
+ }
+ echo '
';
+ } else {
+ echo '' . esc_html__( 'No reference available.', 'tutor' ) . ' ';
+ }
+ }
}
?>
From f837c6344c8fd03ff3c61b4e810eca9f1f5cc139 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Mon, 16 Feb 2026 13:05:07 +0600
Subject: [PATCH 02/15] fix: update FormPinImage component to handle option
checks and improve image handling
- Removed unnecessary field value assignment in PinImage component.
- Enhanced FormPinImage to ensure proper handling of image uploads by checking for the presence of the option before accessing its properties.
- Updated the TutorCore API reference for drawing on images to ensure compatibility with the latest changes.
---
.../components/curriculum/question-types/PinImage.tsx | 4 ----
.../components/fields/quiz/questions/FormPinImage.tsx | 10 +++++++---
2 files changed, 7 insertions(+), 7 deletions(-)
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
index 9c0b1a2d30..2bc91ba5f4 100644
--- 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
@@ -60,10 +60,6 @@ const PinImage = () => {
render={(controllerProps) => (
{
type: 'image',
},
onChange: (file) => {
- if (file && !Array.isArray(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 = {
@@ -128,7 +128,7 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
setIsDrawModeActive(false);
}
},
- initialFiles: option.image_id
+ initialFiles: option?.image_id
? {
id: Number(option.image_id),
url: option.image_url || '',
@@ -191,7 +191,7 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
}
const img = imageRef.current;
const canvas = canvasRef.current;
- const api = typeof window !== 'undefined' ? window.TutorDrawOnImage : undefined;
+ const api = typeof window !== 'undefined' ? window.TutorCore?.drawOnImage : undefined;
if (!img || !canvas || !api?.init) {
return;
}
@@ -224,6 +224,10 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
};
}, []);
+ if (!option) {
+ return null;
+ }
+
const handleSave = () => {
const canvas = canvasRef.current;
if (!canvas) {
From 84ef8747f58cbe275b6f2f1d412cbcd937da96c4 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Tue, 17 Feb 2026 15:39:18 +0600
Subject: [PATCH 03/15] feat: add TODO comment for icon finalization in
QuestionList component
- Added a TODO comment indicating that the icon for the 'Pin on Image' quiz type is not final, to ensure future updates address this aspect.
---
.../course-builder/components/curriculum/QuestionList.tsx | 1 +
1 file changed, 1 insertion(+)
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 e71114903c..3259810b88 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
@@ -113,6 +113,7 @@ const questionTypeOptions: {
{
label: __('Pin on Image', 'tutor'),
value: 'pin_image',
+ // TODO: icon is not final.
icon: 'quizImageAnswer',
isPro: true,
},
From 144e989720b7a1bb5ed5eb7910a857283c165c86 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Wed, 18 Feb 2026 10:30:34 +0600
Subject: [PATCH 04/15] refactor: clean up Quiz class and remove unused methods
related to pin image handling
- Removed the add_pin_image_quiz_file_paths_for_deletion method and its associated documentation as it is no longer needed.
- Simplified the process_pin_image_question_answer method by removing unnecessary parameters to streamline its functionality.
- Updated comments for clarity regarding the handling of pin image questions.
---
classes/Quiz.php | 23 ++---------
models/QuizModel.php | 94 --------------------------------------------
2 files changed, 3 insertions(+), 114 deletions(-)
diff --git a/classes/Quiz.php b/classes/Quiz.php
index 235dc91a90..4c1bd9a5d3 100644
--- a/classes/Quiz.php
+++ b/classes/Quiz.php
@@ -151,7 +151,7 @@ public function __construct( $register_hooks = true ) {
// Pin image: process answer via filter (priority 5 so core runs before Pro custom types).
add_filter( 'tutor_quiz_process_custom_question_answer', array( $this, 'process_pin_image_question_answer' ), 5, 6 );
- add_filter( 'tutor_quiz_quiz_file_paths_for_deletion', array( $this, 'add_pin_image_quiz_file_paths_for_deletion' ), 10, 2 );
+ // Pin/draw image quiz file paths for deletion are added by Tutor Pro via tutor_quiz_quiz_file_paths_for_deletion.
add_action( 'tutor_quiz/answer/review/after', array( $this, 'do_auto_course_complete' ), 10, 3 );
@@ -1184,20 +1184,17 @@ function ( $row ) {
* @param array $custom_answer_data Array with given_answer and is_answer_was_correct.
* @param string $question_type Question type.
* @param array $answers Answer data from request.
- * @param object $question Question object.
- * @param int $question_id Question ID.
- * @param int $attempt_id Attempt ID.
*
* @return array Modified custom_answer_data.
*/
- public function process_pin_image_question_answer( $custom_answer_data, $question_type, $answers, $question, $question_id, $attempt_id ) {
+ public function process_pin_image_question_answer( $custom_answer_data, $question_type, $answers ) {
if ( 'pin_image' !== $question_type ) {
return $custom_answer_data;
}
$given_answer = '';
- // Frontend posts: attempt[attempt_id][quiz_question][question_id][answers][pin][x|y]
+ // Frontend posts: attempt[attempt_id][quiz_question][question_id][answers][pin][x|y].
if ( is_array( $answers ) && isset( $answers['answers']['pin'] ) && is_array( $answers['answers']['pin'] ) ) {
$raw_pin = $answers['answers']['pin'];
$x = isset( $raw_pin['x'] ) ? (float) $raw_pin['x'] : 0.0;
@@ -1220,20 +1217,6 @@ public function process_pin_image_question_answer( $custom_answer_data, $questio
return $custom_answer_data;
}
- /**
- * Add pin_image instructor mask file paths for quiz deletion (filter callback).
- *
- * @since 4.0.0
- *
- * @param string[] $file_paths Paths collected so far.
- * @param int $quiz_id Quiz post ID.
- *
- * @return string[]
- */
- public function add_pin_image_quiz_file_paths_for_deletion( $file_paths, $quiz_id ) {
- return array_merge( (array) $file_paths, QuizModel::get_pin_image_file_paths_for_quiz( $quiz_id ) );
- }
-
/**
* Get answers by quiz id
*
diff --git a/models/QuizModel.php b/models/QuizModel.php
index 7bac855f31..f20ab015f7 100644
--- a/models/QuizModel.php
+++ b/models/QuizModel.php
@@ -588,100 +588,6 @@ public static function delete_files_by_paths( array $paths ) {
}
}
- /**
- * Resolve tutor/quiz-image URLs from rows to absolute file paths.
- * Used by pin_image (and optionally by add-ons); draw_image file handling is in Pro.
- *
- * @since 4.0.0
- *
- * @param array $rows Rows with a URL in the given property (e.g. question answers).
- * @param string $url_property Property name on each row (e.g. '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 pin_image instructor mask files for a quiz.
- *
- * Pin-image questions reuse the same mask storage (uploads/tutor/quiz-image)
- * as draw_image; same pattern as Pro's draw_image file-path helper.
- *
- * @since 4.0.0
- *
- * @param int $quiz_id Quiz post ID.
- *
- * @return string[] Array of absolute file paths.
- */
- public static function get_pin_image_file_paths_for_quiz( $quiz_id ) {
- $paths = array();
- $quiz_id = (int) $quiz_id;
- if ( $quiz_id <= 0 ) {
- return $paths;
- }
-
- $upload_dir = wp_upload_dir();
- if ( ! empty( $upload_dir['error'] ) ) {
- return $paths;
- }
-
- $uploads_base_url = trailingslashit( $upload_dir['baseurl'] );
- $uploads_base_dir = trailingslashit( $upload_dir['basedir'] );
- $quiz_image_url = $uploads_base_url . 'tutor/quiz-image/';
-
- $pin_image_questions = QueryHelper::get_all(
- 'tutor_quiz_questions',
- array(
- 'quiz_id' => $quiz_id,
- 'question_type' => 'pin_image',
- ),
- 'question_id',
- -1
- );
-
- if ( empty( $pin_image_questions ) ) {
- return $paths;
- }
-
- $question_ids = array_map(
- function ( $row ) {
- return (int) $row->question_id;
- },
- $pin_image_questions
- );
-
- $question_answers = QueryHelper::get_all(
- 'tutor_quiz_question_answers',
- array(
- 'belongs_question_id' => $question_ids,
- 'belongs_question_type' => 'pin_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 );
- }
-
/**
* Sorting params added on quiz attempt
*
From 073fb2f36145d3cfc1738e92f74c34e266c778b3 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Wed, 18 Feb 2026 15:21:45 +0600
Subject: [PATCH 05/15] refactor: rename variables for clarity in FormPinImage
component
- Updated variable names from 'w' and 'h' to 'width' and 'height' for better readability and understanding of their purpose in the FormPinImage component.
- Ensured consistent naming conventions to enhance code maintainability.
---
.../components/fields/quiz/questions/FormPinImage.tsx | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
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
index cb893c5ae9..f32e586c94 100644
--- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx
+++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx
@@ -71,15 +71,15 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
}
const rect = container.getBoundingClientRect();
- const w = Math.round(rect.width);
- const h = Math.round(rect.height);
+ const width = Math.round(rect.width);
+ const height = Math.round(rect.height);
- if (!w || !h) {
+ if (!width || !height) {
return;
}
- canvas.width = w;
- canvas.height = h;
+ canvas.width = width;
+ canvas.height = height;
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
From da1607af82d6b26483b51e24d95aff3de5e7c1be Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Fri, 27 Feb 2026 10:51:57 +0600
Subject: [PATCH 06/15] fix(quiz): update filter callback for pin_image grading
to use tutor_filter_quiz_answer_data
---
classes/Quiz.php | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/classes/Quiz.php b/classes/Quiz.php
index 4c1bd9a5d3..3605516487 100644
--- a/classes/Quiz.php
+++ b/classes/Quiz.php
@@ -802,7 +802,6 @@ function ( $ans ) {
// Allow Pro (or add-ons) to grade draw_image and pin_image and set achieved_mark / is_correct.
$answers_data = apply_filters( 'tutor_filter_draw_image_answer_data', $answers_data, $question_id, $question_type, $user_id, $attempt_id );
- $answers_data = apply_filters( 'tutor_filter_pin_image_answer_data', $answers_data, $question_id, $question_type );
$total_marks = apply_filters( 'tutor_quiz_adjust_total_marks_for_question', $total_marks, $question_mark, $answers_data, $question_type, $question_id );
$wpdb->insert( $wpdb->prefix . 'tutor_quiz_attempt_answers', $answers_data );
@@ -1177,7 +1176,7 @@ function ( $row ) {
/**
* Process pin_image question answer (filter callback).
- * Student pin is stored as normalized coordinates JSON; grading is done via tutor_filter_pin_image_answer_data (Pro).
+ * Student pin is stored as normalized coordinates JSON; grading is done via tutor_filter_quiz_answer_data (Pro).
*
* @since 4.0.0
*
From 675cce37796b76c930bc5d71c96009558955992d Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Tue, 3 Mar 2026 11:57:01 +0600
Subject: [PATCH 07/15] fix(quiz): update action hooks for rendering custom
question type answers
---
views/quiz/attempt-details.php | 12 +++++-------
1 file changed, 5 insertions(+), 7 deletions(-)
diff --git a/views/quiz/attempt-details.php b/views/quiz/attempt-details.php
index d8ddb8a811..776d7f24eb 100644
--- a/views/quiz/attempt-details.php
+++ b/views/quiz/attempt-details.php
@@ -543,13 +543,12 @@ function tutor_render_fill_in_the_blank_answer( $get_db_answers_by_question, $an
tutor_render_answer_list( $answers );
} else {
/**
- * Allow Pro and add-ons to render custom question type answers.
+ * Allow Pro and add-ons to render given answer for custom question types.
* Pro handles draw_image and pin_image via this action.
*
- * @param object $answer Answer object.
- * @param string $display_type Display type ('given_answer').
+ * @param object $answer Answer object.
*/
- do_action( 'tutor_quiz_render_answer_for_question_type', $answer, 'given_answer' );
+ do_action( 'tutor_quiz_render_given_answer_for_question_type', $answer );
}
?>
@@ -725,10 +724,9 @@ function( $ans ) {
* Allow Pro and add-ons to render correct answer for custom question types.
* Pro handles draw_image and pin_image via this action.
*
- * @param object $answer Answer object.
- * @param string $display_type Display type ('correct_answer').
+ * @param object $answer Answer object.
*/
- do_action( 'tutor_quiz_render_answer_for_question_type', $answer, 'correct_answer' );
+ do_action( 'tutor_quiz_render_correct_answer_for_question_type', $answer );
}
}
?>
From e289f8227fc8c0d7e6fddecd37f05f19084f6db5 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Wed, 4 Mar 2026 12:58:29 +0600
Subject: [PATCH 08/15] refactor(quiz): remove deprecated pin image question
answer processing
---
classes/Quiz.php | 45 ---------------------------------------------
1 file changed, 45 deletions(-)
diff --git a/classes/Quiz.php b/classes/Quiz.php
index 85fc974e6c..d4d5252157 100644
--- a/classes/Quiz.php
+++ b/classes/Quiz.php
@@ -149,9 +149,6 @@ public function __construct( $register_hooks = true ) {
*/
add_action( 'wp_ajax_tutor_attempt_delete', array( $this, 'attempt_delete' ) );
- // Pin image: process answer via filter (priority 5 so core runs before Pro custom types).
- add_filter( 'tutor_quiz_process_custom_question_answer', array( $this, 'process_pin_image_question_answer' ), 5, 6 );
- // Pin/draw image quiz file paths for deletion are added by Tutor Pro via tutor_quiz_quiz_file_paths_for_deletion.
add_action( 'tutor_quiz/answer/review/after', array( $this, 'do_auto_course_complete' ), 10, 3 );
@@ -1172,48 +1169,6 @@ function ( $row ) {
);
}
- /**
- * Process pin_image question answer (filter callback).
- * Student pin is stored as normalized coordinates JSON; grading is done via tutor_filter_quiz_answer_data (Pro).
- *
- * @since 4.0.0
- *
- * @param array $custom_answer_data Array with given_answer and is_answer_was_correct.
- * @param string $question_type Question type.
- * @param array $answers Answer data from request.
- *
- * @return array Modified custom_answer_data.
- */
- public function process_pin_image_question_answer( $custom_answer_data, $question_type, $answers ) {
- if ( 'pin_image' !== $question_type ) {
- return $custom_answer_data;
- }
-
- $given_answer = '';
-
- // Frontend posts: attempt[attempt_id][quiz_question][question_id][answers][pin][x|y].
- if ( is_array( $answers ) && isset( $answers['answers']['pin'] ) && is_array( $answers['answers']['pin'] ) ) {
- $raw_pin = $answers['answers']['pin'];
- $x = isset( $raw_pin['x'] ) ? (float) $raw_pin['x'] : 0.0;
- $y = isset( $raw_pin['y'] ) ? (float) $raw_pin['y'] : 0.0;
-
- $x = max( 0.0, min( 1.0, $x ) );
- $y = max( 0.0, min( 1.0, $y ) );
-
- $given_answer = wp_json_encode(
- array(
- 'x' => $x,
- 'y' => $y,
- )
- );
- }
-
- $custom_answer_data['given_answer'] = $given_answer;
- $custom_answer_data['is_answer_was_correct'] = false;
-
- return $custom_answer_data;
- }
-
/**
* Get answers by quiz id
*
From 1484b31e480f0936131c337e69a066df188f0ad2 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Fri, 27 Mar 2026 13:24:58 +0600
Subject: [PATCH 09/15] feat: Add Pin on Image question type for quizzes with
rendering and review templates
---
.../quiz/questions/pin-image.php | 133 ++++++++++++++++++
.../attempt-details/questions/pin-image.php | 97 +++++++++++++
.../quiz/attempt-details/review-answers.php | 3 +
3 files changed, 233 insertions(+)
create mode 100644 templates/learning-area/quiz/questions/pin-image.php
create mode 100644 templates/shared/components/quiz/attempt-details/questions/pin-image.php
diff --git a/templates/learning-area/quiz/questions/pin-image.php b/templates/learning-area/quiz/questions/pin-image.php
new file mode 100644
index 0000000000..cac1b066c3
--- /dev/null
+++ b/templates/learning-area/quiz/questions/pin-image.php
@@ -0,0 +1,133 @@
+attempt_id ) ? (int) $tutor_is_started_quiz->attempt_id : 0;
+ $quiz_id = isset( $tutor_is_started_quiz->quiz_id ) ? (int) $tutor_is_started_quiz->quiz_id : 0;
+}
+
+$answers = isset( $question['question_answers'] ) && is_array( $question['question_answers'] ) ? $question['question_answers'] : array();
+$answer = ! empty( $answers ) ? reset( $answers ) : null;
+
+if ( ! is_array( $answer ) ) {
+ return;
+}
+
+// Signal Pro fallback renderer to skip duplicate output for this question.
+if ( ! isset( $GLOBALS['tutor_learning_area_pin_image_rendered'] ) || ! is_array( $GLOBALS['tutor_learning_area_pin_image_rendered'] ) ) {
+ $GLOBALS['tutor_learning_area_pin_image_rendered'] = array();
+}
+$GLOBALS['tutor_learning_area_pin_image_rendered'][ $question_id ] = true;
+
+// Request script enqueue from Pro so existing asset/hook controls remain centralized.
+do_action( 'tutor_enqueue_pin_image_question_script' );
+
+$bg_image_url = '';
+if ( isset( $answer['image_id'] ) ) {
+ $bg_image_url = QuizModel::get_answer_image_url( (object) $answer );
+}
+
+$question_type = (string) ( $question['question_type'] ?? 'pin_image' );
+$question_settings = isset( $question['question_settings'] ) && is_array( $question['question_settings'] ) ? $question['question_settings'] : array();
+$answer_is_required = isset( $question_settings['answer_required'] ) && '1' === (string) $question_settings['answer_required'];
+$is_reveal_mode = 'reveal' === tutor_utils()->get_quiz_option( $quiz_id, 'feedback_mode', '' );
+$instructor_mask = ! empty( $answer['answer_two_gap_match'] ) ? (string) $answer['answer_two_gap_match'] : '';
+$instructor_mask_is_url = false !== wp_http_validate_url( $instructor_mask );
+
+$wrapper_id = 'tutor-pin-image-question-' . $question_id;
+$image_id = 'tutor-pin-image-bg-' . $question_id;
+$pin_x_input_id = 'tutor-pin-image-x-' . $question_id;
+$pin_y_input_id = 'tutor-pin-image-y-' . $question_id;
+
+$pin_x_field_name = sprintf( '%s[answers][pin][x]', $question_field_name_base ?? '' );
+$pin_y_field_name = sprintf( '%s[answers][pin][y]', $question_field_name_base ?? '' );
+$register_rules = '';
+if ( $answer_is_required ) {
+ $register_rules = ", { required: '" . esc_js( $required_message ) . "' }";
+}
+$pin_x_register_attr = "register('{$pin_x_field_name}'{$register_rules})";
+$pin_y_register_attr = "register('{$pin_y_field_name}'{$register_rules})";
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/shared/components/quiz/attempt-details/questions/pin-image.php b/templates/shared/components/quiz/attempt-details/questions/pin-image.php
new file mode 100644
index 0000000000..9d790876a4
--- /dev/null
+++ b/templates/shared/components/quiz/attempt-details/questions/pin-image.php
@@ -0,0 +1,97 @@
+question_id, false );
+$pin_answers = is_array( $pin_answers ) ? $pin_answers : array();
+$correct_answer = ! empty( $pin_answers ) ? reset( $pin_answers ) : null;
+
+$background_url = $correct_answer ? QuizModel::get_answer_image_url( $correct_answer ) : '';
+$reference_mask = $correct_answer && ! empty( $correct_answer->answer_two_gap_match ) ? (string) $correct_answer->answer_two_gap_match : '';
+$has_reference = false !== wp_http_validate_url( $reference_mask );
+
+$coords = null;
+if ( $attempt_answer && ! empty( $attempt_answer->given_answer ) ) {
+ $given_answer = maybe_unserialize( $attempt_answer->given_answer );
+ $decoded = null;
+
+ if ( is_array( $given_answer ) ) {
+ $decoded = $given_answer;
+ } elseif ( is_string( $given_answer ) ) {
+ $decoded = json_decode( stripslashes( $given_answer ), true );
+ if ( ! is_array( $decoded ) ) {
+ $decoded = json_decode( $given_answer, true );
+ }
+ }
+
+ if ( is_array( $decoded ) && isset( $decoded['pin'] ) && is_array( $decoded['pin'] ) ) {
+ $decoded = $decoded['pin'];
+ }
+
+ if ( is_array( $decoded ) && isset( $decoded['x'], $decoded['y'] ) ) {
+ $coords = array(
+ 'x' => max( 0.0, min( 1.0, (float) $decoded['x'] ) ),
+ 'y' => max( 0.0, min( 1.0, (float) $decoded['y'] ) ),
+ );
+ }
+}
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/shared/components/quiz/attempt-details/review-answers.php b/templates/shared/components/quiz/attempt-details/review-answers.php
index 34dfd7cd68..83dd1bbf13 100644
--- a/templates/shared/components/quiz/attempt-details/review-answers.php
+++ b/templates/shared/components/quiz/attempt-details/review-answers.php
@@ -41,6 +41,7 @@
$is_mc_review = in_array( $question_type, array( 'single_choice', 'multiple_choice' ), true );
$is_oe_review = in_array( $question_type, array( 'open_ended', 'short_answer' ), true );
$is_fib_review = 'fill_in_the_blank' === $question_type;
+ $is_pin_review = 'pin_image' === $question_type;
$attempt_answer = $attempt_answers_map[ $question_id ] ?? null;
$question_template = '';
@@ -54,6 +55,8 @@
$question_template = 'open-ended';
} elseif ( $is_fib_review ) {
$question_template = 'fill-in-the-blank';
+ } elseif ( $is_pin_review ) {
+ $question_template = 'pin-image';
}
?>
From f90f39512633321ccb6b5cc93dd218af06f561c9 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Fri, 27 Mar 2026 14:16:30 +0600
Subject: [PATCH 10/15] feat: Enhance Pin on Image question type with lasso
drawing functionality and improved reference mask handling
---
assets/src/js/front/course/_spotlight-quiz.js | 1 +
.../fields/quiz/questions/FormPinImage.tsx | 154 ++++++++++++++++--
.../quiz/questions/pin-image.php | 66 ++++++--
.../attempt-details/questions/pin-image.php | 71 +++++++-
4 files changed, 259 insertions(+), 33 deletions(-)
diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js
index 428cfc6dcb..bb76c2c3d4 100644
--- a/assets/src/js/front/course/_spotlight-quiz.js
+++ b/assets/src/js/front/course/_spotlight-quiz.js
@@ -106,6 +106,7 @@ window.jQuery(document).ready($ => {
if (is_reveal_mode() && ['draw_image', 'pin_image'].includes($question_wrap.data('question-type'))) {
$question_wrap.find('.tutor-quiz-explanation-wrapper').removeClass('tutor-d-none');
$question_wrap.find('.tutor-draw-image-reference-wrapper').removeClass('tutor-d-none');
+ $question_wrap.find('.tutor-pin-image-reference-wrapper').removeClass('tutor-d-none');
goNext = true;
}
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
index f32e586c94..29dafb61b4 100644
--- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx
+++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx
@@ -20,7 +20,10 @@ import {
type QuizValidationErrorType,
} from '@TutorShared/utils/types';
-const INSTRUCTOR_STROKE_STYLE = 'rgba(0, 120, 255, 0.9)';
+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;
@@ -44,6 +47,9 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
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) => {
@@ -184,30 +190,148 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
};
}, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]);
- // Wire to shared draw-on-image module when draw mode is active (Tutor Pro).
+ // Pin image uses lasso-style polygon drawing for marking the valid pin zone.
useEffect(() => {
if (!isDrawModeActive || !option?.image_url) {
return;
}
- const img = imageRef.current;
const canvas = canvasRef.current;
- const api = typeof window !== 'undefined' ? window.TutorCore?.drawOnImage : undefined;
- if (!img || !canvas || !api?.init) {
+ if (!canvas) {
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,
- });
+
+ 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;
@@ -357,7 +481,7 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
@@ -388,7 +512,7 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
{__(
- 'Use the brush to draw on the image where a pin should be considered correct, then click Save.',
+ 'Use lasso drawing to wrap the valid pin zone. You can draw multiple lasso regions, then click Save.',
__TUTOR_TEXT_DOMAIN__,
)}
diff --git a/templates/learning-area/quiz/questions/pin-image.php b/templates/learning-area/quiz/questions/pin-image.php
index cac1b066c3..599a44a4a5 100644
--- a/templates/learning-area/quiz/questions/pin-image.php
+++ b/templates/learning-area/quiz/questions/pin-image.php
@@ -50,12 +50,18 @@
$bg_image_url = QuizModel::get_answer_image_url( (object) $answer );
}
-$question_type = (string) ( $question['question_type'] ?? 'pin_image' );
-$question_settings = isset( $question['question_settings'] ) && is_array( $question['question_settings'] ) ? $question['question_settings'] : array();
-$answer_is_required = isset( $question_settings['answer_required'] ) && '1' === (string) $question_settings['answer_required'];
-$is_reveal_mode = 'reveal' === tutor_utils()->get_quiz_option( $quiz_id, 'feedback_mode', '' );
-$instructor_mask = ! empty( $answer['answer_two_gap_match'] ) ? (string) $answer['answer_two_gap_match'] : '';
-$instructor_mask_is_url = false !== wp_http_validate_url( $instructor_mask );
+$question_type = (string) ( $question['question_type'] ?? 'pin_image' );
+$question_settings = isset( $question['question_settings'] ) && is_array( $question['question_settings'] ) ? $question['question_settings'] : array();
+$answer_is_required = isset( $question_settings['answer_required'] ) && '1' === (string) $question_settings['answer_required'];
+$is_reveal_mode = 'reveal' === tutor_utils()->get_quiz_option( $quiz_id, 'feedback_mode', '' );
+$instructor_mask = ! empty( $answer['answer_two_gap_match'] ) ? (string) $answer['answer_two_gap_match'] : '';
+$instructor_mask = trim( $instructor_mask );
+$instructor_mask_is_url = false !== wp_http_validate_url( $instructor_mask );
+$instructor_mask_is_data =
+ 0 === strpos( $instructor_mask, 'data:image/' ) &&
+ false !== strpos( $instructor_mask, ';base64,' );
+$instructor_has_mask = $instructor_mask_is_url || $instructor_mask_is_data;
+$instructor_mask_css = $instructor_mask_is_url ? esc_url_raw( $instructor_mask ) : $instructor_mask;
$wrapper_id = 'tutor-pin-image-question-' . $question_id;
$image_id = 'tutor-pin-image-bg-' . $question_id;
@@ -86,7 +92,7 @@ class="quiz-question-ans-choice-area tutor-mt-40 tutor-pin-image-question questi
/>
-
+
@@ -97,12 +103,11 @@ class="tutor-pin-image-reference-bg"
src=""
alt=""
/>
- "
role="presentation"
- />
+ >
@@ -131,3 +136,40 @@ class="tutor-pin-image-reference-mask"
+
+
diff --git a/templates/shared/components/quiz/attempt-details/questions/pin-image.php b/templates/shared/components/quiz/attempt-details/questions/pin-image.php
index 9d790876a4..e11ca2abab 100644
--- a/templates/shared/components/quiz/attempt-details/questions/pin-image.php
+++ b/templates/shared/components/quiz/attempt-details/questions/pin-image.php
@@ -20,9 +20,16 @@
$pin_answers = is_array( $pin_answers ) ? $pin_answers : array();
$correct_answer = ! empty( $pin_answers ) ? reset( $pin_answers ) : null;
-$background_url = $correct_answer ? QuizModel::get_answer_image_url( $correct_answer ) : '';
-$reference_mask = $correct_answer && ! empty( $correct_answer->answer_two_gap_match ) ? (string) $correct_answer->answer_two_gap_match : '';
-$has_reference = false !== wp_http_validate_url( $reference_mask );
+$background_url = $correct_answer ? QuizModel::get_answer_image_url( $correct_answer ) : '';
+$reference_mask = $correct_answer && ! empty( $correct_answer->answer_two_gap_match ) ? (string) $correct_answer->answer_two_gap_match : '';
+$reference_mask = trim( $reference_mask );
+$reference_is_url = false !== wp_http_validate_url( $reference_mask );
+$reference_is_data =
+ 0 === strpos( $reference_mask, 'data:image/' ) &&
+ false !== strpos( $reference_mask, ';base64,' );
+$has_reference = $reference_is_url || $reference_is_data;
+$reference_mask_css = $reference_is_url ? esc_url_raw( $reference_mask ) : $reference_mask;
+$wrapper_id = 'tutor-pin-image-attempt-' . (int) $question->question_id;
$coords = null;
if ( $attempt_answer && ! empty( $attempt_answer->given_answer ) ) {
@@ -51,7 +58,7 @@
}
?>
-
+
@@ -61,7 +68,11 @@
-
+
"
+ role="presentation"
+ >
-
+
+ "
+ role="presentation"
+ >
+
@@ -95,3 +112,45 @@ class="tutor-pin-image-marker"
+
From 8a62f2db89058ec4d7605a7a0729668b0f0ce581 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Mon, 30 Mar 2026 10:46:19 +0600
Subject: [PATCH 11/15] feat: Refactor FormPinImage component to improve lasso
drawing interactions and enhance UI elements for pin image questions
---
.../fields/quiz/questions/FormPinImage.tsx | 141 +++++++++++-------
.../attempt-details/questions/pin-image.php | 54 -------
2 files changed, 87 insertions(+), 108 deletions(-)
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
index 29dafb61b4..7dbea0fa44 100644
--- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx
+++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormPinImage.tsx
@@ -2,7 +2,6 @@ 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';
@@ -348,13 +347,9 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
};
}, []);
- if (!option) {
- return null;
- }
-
- const handleSave = () => {
+ const persistCanvasMask = useCallback(() => {
const canvas = canvasRef.current;
- if (!canvas) {
+ if (!canvas || !option) {
return;
}
@@ -373,15 +368,13 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
is_saved: true,
};
updateOption(updated);
+ }, [option, updateOption]);
- if (drawInstanceRef.current) {
- drawInstanceRef.current.destroy();
- drawInstanceRef.current = null;
+ const handleClear = () => {
+ if (!option) {
+ return;
}
- setIsDrawModeActive(false);
- };
- const handleClear = () => {
if (drawInstanceRef.current) {
drawInstanceRef.current.destroy();
drawInstanceRef.current = null;
@@ -402,14 +395,23 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
is_saved: true,
};
updateOption(updated);
- setIsDrawModeActive(false);
};
- const handleDraw = () => {
+ 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;
@@ -435,6 +437,32 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
}
};
+ 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 */}
@@ -454,13 +482,13 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
infoText={__('Upload the base image students will pin on.', __TUTOR_TEXT_DOMAIN__)}
uploadHandler={openMediaLibrary}
clearHandler={clearImage}
- emptyImageCss={styles.imageInput}
- previewImageCss={styles.imageInput}
+ emptyImageCss={styles.imageInputEmpty}
+ previewImageCss={styles.imageInputPreview}
/>
- {/* Section 2: Mark the valid pin area — single reference image + drawing canvas; Save / Clear / Draw buttons */}
+ {/* Section 2: Mark the valid pin area — drawing auto-enables on image hover */}
@@ -468,10 +496,16 @@ const FormPinImage = ({ field }: FormPinImageProps) => {
- {__('Mark the valid pin area', __TUTOR_TEXT_DOMAIN__)}
+ {__('Mark the correct area', __TUTOR_TEXT_DOMAIN__)}
+
+
+
+ {__('Clear', __TUTOR_TEXT_DOMAIN__)}
+
+
-
+
{
aria-label={__('Draw a lasso around the valid pin area', __TUTOR_TEXT_DOMAIN__)}
/>
-
- }
- >
- {__('Save', __TUTOR_TEXT_DOMAIN__)}
-
- }
- >
- {__('Clear', __TUTOR_TEXT_DOMAIN__)}
-
- }
- >
- {__('Draw', __TUTOR_TEXT_DOMAIN__)}
-
-
-
- {__(
- 'Use lasso drawing to wrap the valid pin zone. You can draw multiple lasso regions, then click Save.',
- __TUTOR_TEXT_DOMAIN__,
- )}
-
{__('Pin area saved. Students will be graded against this region.', __TUTOR_TEXT_DOMAIN__)}
@@ -553,15 +555,27 @@ const styles = {
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`
+ 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;
@@ -603,6 +617,7 @@ const styles = {
position: absolute;
top: 0;
left: 0;
+ z-index: 1;
`,
canvasIdleMode: css`
pointer-events: none;
@@ -616,6 +631,24 @@ const styles = {
${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()};
diff --git a/templates/shared/components/quiz/attempt-details/questions/pin-image.php b/templates/shared/components/quiz/attempt-details/questions/pin-image.php
index e11ca2abab..c384bf9622 100644
--- a/templates/shared/components/quiz/attempt-details/questions/pin-image.php
+++ b/templates/shared/components/quiz/attempt-details/questions/pin-image.php
@@ -60,9 +60,6 @@
-
-
-
@@ -81,15 +78,6 @@ class="tutor-pin-image-marker"
>
-
-
-
-
-
-
-
-
-
-
From 9daf9245b79d2687ffc0bb8baba7c82a0697fffa Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Thu, 2 Apr 2026 12:50:08 +0600
Subject: [PATCH 12/15] Add support for 'Pin Image' question type in review
answers template
- Introduced a new variable to handle 'pin_image' question type in the review answers template.
- This enhancement allows for better handling of different question types during quiz attempts.
---
.../shared/components/quiz/attempt-details/review-answers.php | 2 ++
1 file changed, 2 insertions(+)
diff --git a/templates/shared/components/quiz/attempt-details/review-answers.php b/templates/shared/components/quiz/attempt-details/review-answers.php
index dd06174aba..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 = '';
From 712ad19e52d871823a5f60b4fe2cc935349c567d Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Thu, 2 Apr 2026 13:16:16 +0600
Subject: [PATCH 13/15] Enhance legacy learning mode handling for question
types
- Updated the QuestionList component to filter out both 'Draw on Image' and 'Pin on Image' question types when legacy learning mode is enabled.
- Added exception handling in the QuizBuilder class to throw appropriate messages when attempting to use 'Pin on Image' questions in legacy mode.
---
.../course-builder/components/curriculum/QuestionList.tsx | 2 +-
classes/QuizBuilder.php | 4 ++++
2 files changed, 5 insertions(+), 1 deletion(-)
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 ab2ce0e3b0..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
@@ -124,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;
}, []);
diff --git a/classes/QuizBuilder.php b/classes/QuizBuilder.php
index ad7aff322d..aa808b5feb 100644
--- a/classes/QuizBuilder.php
+++ b/classes/QuizBuilder.php
@@ -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();
From 73519a39cbeb8ab3f93bbadcb0b619eb6a23aadf Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Fri, 3 Apr 2026 11:55:08 +0600
Subject: [PATCH 14/15] Remove Pin on Image question template files and update
Quiz class to include new question type. This change enhances the quiz
functionality by adding support for 'pin-image' questions while cleaning up
unused templates.
---
classes/Quiz.php | 2 +
.../quiz/questions/pin-image.php | 175 ------------------
.../attempt-details/questions/pin-image.php | 102 ----------
3 files changed, 2 insertions(+), 277 deletions(-)
delete mode 100644 templates/learning-area/quiz/questions/pin-image.php
delete mode 100644 templates/shared/components/quiz/attempt-details/questions/pin-image.php
diff --git a/classes/Quiz.php b/classes/Quiz.php
index b4e606eefa..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
diff --git a/templates/learning-area/quiz/questions/pin-image.php b/templates/learning-area/quiz/questions/pin-image.php
deleted file mode 100644
index 599a44a4a5..0000000000
--- a/templates/learning-area/quiz/questions/pin-image.php
+++ /dev/null
@@ -1,175 +0,0 @@
-attempt_id ) ? (int) $tutor_is_started_quiz->attempt_id : 0;
- $quiz_id = isset( $tutor_is_started_quiz->quiz_id ) ? (int) $tutor_is_started_quiz->quiz_id : 0;
-}
-
-$answers = isset( $question['question_answers'] ) && is_array( $question['question_answers'] ) ? $question['question_answers'] : array();
-$answer = ! empty( $answers ) ? reset( $answers ) : null;
-
-if ( ! is_array( $answer ) ) {
- return;
-}
-
-// Signal Pro fallback renderer to skip duplicate output for this question.
-if ( ! isset( $GLOBALS['tutor_learning_area_pin_image_rendered'] ) || ! is_array( $GLOBALS['tutor_learning_area_pin_image_rendered'] ) ) {
- $GLOBALS['tutor_learning_area_pin_image_rendered'] = array();
-}
-$GLOBALS['tutor_learning_area_pin_image_rendered'][ $question_id ] = true;
-
-// Request script enqueue from Pro so existing asset/hook controls remain centralized.
-do_action( 'tutor_enqueue_pin_image_question_script' );
-
-$bg_image_url = '';
-if ( isset( $answer['image_id'] ) ) {
- $bg_image_url = QuizModel::get_answer_image_url( (object) $answer );
-}
-
-$question_type = (string) ( $question['question_type'] ?? 'pin_image' );
-$question_settings = isset( $question['question_settings'] ) && is_array( $question['question_settings'] ) ? $question['question_settings'] : array();
-$answer_is_required = isset( $question_settings['answer_required'] ) && '1' === (string) $question_settings['answer_required'];
-$is_reveal_mode = 'reveal' === tutor_utils()->get_quiz_option( $quiz_id, 'feedback_mode', '' );
-$instructor_mask = ! empty( $answer['answer_two_gap_match'] ) ? (string) $answer['answer_two_gap_match'] : '';
-$instructor_mask = trim( $instructor_mask );
-$instructor_mask_is_url = false !== wp_http_validate_url( $instructor_mask );
-$instructor_mask_is_data =
- 0 === strpos( $instructor_mask, 'data:image/' ) &&
- false !== strpos( $instructor_mask, ';base64,' );
-$instructor_has_mask = $instructor_mask_is_url || $instructor_mask_is_data;
-$instructor_mask_css = $instructor_mask_is_url ? esc_url_raw( $instructor_mask ) : $instructor_mask;
-
-$wrapper_id = 'tutor-pin-image-question-' . $question_id;
-$image_id = 'tutor-pin-image-bg-' . $question_id;
-$pin_x_input_id = 'tutor-pin-image-x-' . $question_id;
-$pin_y_input_id = 'tutor-pin-image-y-' . $question_id;
-
-$pin_x_field_name = sprintf( '%s[answers][pin][x]', $question_field_name_base ?? '' );
-$pin_y_field_name = sprintf( '%s[answers][pin][y]', $question_field_name_base ?? '' );
-$register_rules = '';
-if ( $answer_is_required ) {
- $register_rules = ", { required: '" . esc_js( $required_message ) . "' }";
-}
-$pin_x_register_attr = "register('{$pin_x_field_name}'{$register_rules})";
-$pin_y_register_attr = "register('{$pin_y_field_name}'{$register_rules})";
-?>
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
"
- role="presentation"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/templates/shared/components/quiz/attempt-details/questions/pin-image.php b/templates/shared/components/quiz/attempt-details/questions/pin-image.php
deleted file mode 100644
index c384bf9622..0000000000
--- a/templates/shared/components/quiz/attempt-details/questions/pin-image.php
+++ /dev/null
@@ -1,102 +0,0 @@
-question_id, false );
-$pin_answers = is_array( $pin_answers ) ? $pin_answers : array();
-$correct_answer = ! empty( $pin_answers ) ? reset( $pin_answers ) : null;
-
-$background_url = $correct_answer ? QuizModel::get_answer_image_url( $correct_answer ) : '';
-$reference_mask = $correct_answer && ! empty( $correct_answer->answer_two_gap_match ) ? (string) $correct_answer->answer_two_gap_match : '';
-$reference_mask = trim( $reference_mask );
-$reference_is_url = false !== wp_http_validate_url( $reference_mask );
-$reference_is_data =
- 0 === strpos( $reference_mask, 'data:image/' ) &&
- false !== strpos( $reference_mask, ';base64,' );
-$has_reference = $reference_is_url || $reference_is_data;
-$reference_mask_css = $reference_is_url ? esc_url_raw( $reference_mask ) : $reference_mask;
-$wrapper_id = 'tutor-pin-image-attempt-' . (int) $question->question_id;
-
-$coords = null;
-if ( $attempt_answer && ! empty( $attempt_answer->given_answer ) ) {
- $given_answer = maybe_unserialize( $attempt_answer->given_answer );
- $decoded = null;
-
- if ( is_array( $given_answer ) ) {
- $decoded = $given_answer;
- } elseif ( is_string( $given_answer ) ) {
- $decoded = json_decode( stripslashes( $given_answer ), true );
- if ( ! is_array( $decoded ) ) {
- $decoded = json_decode( $given_answer, true );
- }
- }
-
- if ( is_array( $decoded ) && isset( $decoded['pin'] ) && is_array( $decoded['pin'] ) ) {
- $decoded = $decoded['pin'];
- }
-
- if ( is_array( $decoded ) && isset( $decoded['x'], $decoded['y'] ) ) {
- $coords = array(
- 'x' => max( 0.0, min( 1.0, (float) $decoded['x'] ) ),
- 'y' => max( 0.0, min( 1.0, (float) $decoded['y'] ) ),
- );
- }
-}
-?>
-
-
-
-
-
-
-
-
-
"
- role="presentation"
- >
-
-
-
-
-
-
-
- "
- role="presentation"
- >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
From 474db21e13cc65e939449e32df1aed1b6d2378b1 Mon Sep 17 00:00:00 2001
From: Sadman Soumique
Date: Fri, 3 Apr 2026 13:49:13 +0600
Subject: [PATCH 15/15] Refactor spotlight quiz JavaScript to remove support
for 'draw_image' and 'pin_image' question types. This change simplifies the
quiz functionality by focusing on supported question types and enhancing code
maintainability.
---
assets/src/js/front/course/_spotlight-quiz.js | 36 ++-----------------
1 file changed, 2 insertions(+), 34 deletions(-)
diff --git a/assets/src/js/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js
index bb76c2c3d4..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', 'pin_image'];
+ const revealModeSupportedQuestions = ['true_false', 'single_choice', 'multiple_choice'];
let quiz_options = _tutorobject.quiz_options
let interactions = new Map();
@@ -102,14 +102,6 @@ window.jQuery(document).ready($ => {
});
}
- // Reveal mode for draw_image & pin_image: show reference (instructor mask) and explanation.
- if (is_reveal_mode() && ['draw_image', 'pin_image'].includes($question_wrap.data('question-type'))) {
- $question_wrap.find('.tutor-quiz-explanation-wrapper').removeClass('tutor-d-none');
- $question_wrap.find('.tutor-draw-image-reference-wrapper').removeClass('tutor-d-none');
- $question_wrap.find('.tutor-pin-image-reference-wrapper').removeClass('tutor-d-none');
- goNext = true;
- }
-
if (validatedTrue) {
goNext = true;
}
@@ -168,25 +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 ($question_wrap.data('question-type') === 'pin_image') {
- // Pin image: require normalized pin coordinates (hidden inputs [answers][pin][x|y]).
- var $pinX = $required_answer_wrap.find('input[name*="[answers][pin][x]"]');
- var $pinY = $required_answer_wrap.find('input[name*="[answers][pin][y]"]');
- if (
- !$pinX.length || !$pinY.length ||
- !$pinX.val().trim().length || !$pinY.val().trim().length
- ) {
- $question_wrap.find('.answer-help-block').html(`${__('Please drop a pin 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;
@@ -245,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();