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__)}
+
+
+
+
+
+
+

+
+
+
+
+ {__('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.
*/