- {__('Use the brush to draw on the image, then click Save to store the answer zone.', __TUTOR_TEXT_DOMAIN__)}
-
{__('Answer zone saved. Students will be graded against this area.', __TUTOR_TEXT_DOMAIN__)}
@@ -443,6 +581,16 @@ const styles = {
imageInput: css`
border-radius: ${borderRadius.card};
`,
+ uploadedImageWrapper: css`
+ max-width: 100%;
+ `,
+ uploadedImage: css`
+ display: block;
+ width: 100%;
+ height: auto;
+ cursor: pointer;
+ border-radius: ${borderRadius.card};
+ `,
answerHeader: css`
${styleUtils.display.flex('row')};
align-items: center;
@@ -484,6 +632,7 @@ const styles = {
position: absolute;
top: 0;
left: 0;
+ z-index: 1;
`,
canvasIdleMode: css`
pointer-events: none;
@@ -493,15 +642,40 @@ const styles = {
pointer-events: auto;
cursor: crosshair;
`,
- actionsRow: css`
+ drawBadge: css`
+ position: absolute;
+ top: ${spacing[12]};
+ right: ${spacing[12]};
+ z-index: 2;
+ width: 32px;
+ height: 32px;
+ border-radius: 999px;
+ background: ${colorTokens.surface.tutor};
+ border: 1px solid ${colorTokens.stroke.border};
${styleUtils.display.flex('row')};
- gap: ${spacing[12]};
- flex-wrap: wrap;
- `,
- brushHint: css`
- ${typography.caption()};
+ align-items: center;
+ justify-content: center;
color: ${colorTokens.text.subdued};
- margin: 0;
+ box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
+ `,
+ 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;
+ `,
+ clearButtonIcon: css`
+ color: ${colorTokens.text.brand};
+ `,
+ clearIcon: css`
+ color: ${colorTokens.text.brand};
`,
savedHint: css`
${typography.caption()};
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/config/config.ts b/assets/src/js/v3/shared/config/config.ts
index f74c543c13..d229a2cebf 100644
--- a/assets/src/js/v3/shared/config/config.ts
+++ b/assets/src/js/v3/shared/config/config.ts
@@ -43,6 +43,7 @@ const defaultTutorConfig = {
addons_data: [],
kids_icons_registry: [],
is_kids_mode: false,
+ is_legacy_learning_mode: false,
current_user: {
data: {
id: '',
diff --git a/assets/src/js/v3/shared/utils/quiz.ts b/assets/src/js/v3/shared/utils/quiz.ts
index 5a743e6506..d886b6fdf1 100644
--- a/assets/src/js/v3/shared/utils/quiz.ts
+++ b/assets/src/js/v3/shared/utils/quiz.ts
@@ -114,6 +114,14 @@ export const convertedQuestion = (question: Omit):
question.question_settings.answer_required = !!Number(question.question_settings.answer_required);
question.question_settings.show_question_mark = !!Number(question.question_settings.show_question_mark);
question.question_settings.randomize_question = !!Number(question.question_settings.randomize_question);
+ if (question.question_type === 'draw_image') {
+ const rawThreshold = (question.question_settings as { draw_image_threshold_percent?: number | string })
+ .draw_image_threshold_percent;
+ if (rawThreshold !== undefined && rawThreshold !== null && !Number.isNaN(Number(rawThreshold))) {
+ (question.question_settings as { draw_image_threshold_percent?: number }).draw_image_threshold_percent =
+ Number(rawThreshold);
+ }
+ }
}
question.question_answers = question.question_answers.map((answer) => ({
...answer,
diff --git a/assets/src/js/v3/shared/utils/types.ts b/assets/src/js/v3/shared/utils/types.ts
index aa117bdad6..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 {
@@ -331,6 +332,7 @@ export interface QuizQuestion {
show_question_mark: boolean;
has_multiple_correct_answer: boolean;
is_image_matching: boolean;
+ draw_image_threshold_percent?: number;
};
question_answers: QuizQuestionOption[];
}
@@ -345,6 +347,7 @@ export interface QuizQuestionsForPayload extends Omit tutor_utils()->get_option( 'monetize_by' ),
'kids_icons_registry' => $kids_icons,
'is_kids_mode' => tutor_utils()->is_kids_mode(),
+ 'is_legacy_learning_mode' => tutor_utils()->is_legacy_learning_mode(),
);
}
diff --git a/classes/Quiz.php b/classes/Quiz.php
index 64c1d95922..31a730709a 100644
--- a/classes/Quiz.php
+++ b/classes/Quiz.php
@@ -1254,7 +1254,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 e7086d44d5..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 ?? '', '' );
@@ -169,6 +169,8 @@ public function save_question_answers( $question_id, $question_type, $question_a
* @param array $questions questions data.
*
* @return void
+ *
+ * @throws \Exception When saving a draw_image question while Legacy learning mode is enabled.
*/
public function save_questions( $quiz_id, $questions ) {
global $wpdb;
@@ -185,6 +187,14 @@ public function save_questions( $quiz_id, $questions ) {
}
$question_type = Input::sanitize( $question['question_type'] );
+ if ( 'draw_image' === $question_type && tutor_utils()->is_legacy_learning_mode() ) {
+ $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 7ca81fa57f..50ee5f2c91 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 ] ) ) {
@@ -11135,4 +11140,15 @@ public static function get_icon_by_post_type( $post_type ): string {
public static function is_kids_mode(): bool {
return Options_V2::LEARNING_MODE_KIDS === tutor_utils()->get_option( 'learning_mode' ) && User::is_student_view();
}
+
+ /**
+ * Is legacy learning mode active?
+ *
+ * @since 4.0.0
+ *
+ * @return bool
+ */
+ public static function is_legacy_learning_mode(): bool {
+ return Options_V2::LEARNING_MODE_LEGACY === tutor_utils()->get_option( 'learning_mode' );
+ }
}
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/learning-area/quiz/questions/draw-image.php b/templates/learning-area/quiz/questions/draw-image.php
new file mode 100644
index 0000000000..f9a2acadfe
--- /dev/null
+++ b/templates/learning-area/quiz/questions/draw-image.php
@@ -0,0 +1,134 @@
+ 0 ) {
+ $GLOBALS['tutor_learning_area_draw_image_rendered'][ $question_id ] = true;
+}
+
+$answers = isset( $question['question_answers'] ) && is_array( $question['question_answers'] ) ? $question['question_answers'] : array();
+$answer = ! empty( $answers ) ? reset( $answers ) : null;
+
+if ( ! $answer ) {
+ return;
+}
+
+$answer_obj = is_array( $answer ) ? (object) $answer : $answer;
+
+$bg_image_url = QuizModel::get_answer_image_url( $answer_obj );
+
+$quiz_id_attempt = is_object( $tutor_is_started_quiz ) && isset( $tutor_is_started_quiz->quiz_id ) ? (int) $tutor_is_started_quiz->quiz_id : 0;
+$is_reveal_mode = $quiz_id_attempt > 0 && QuizClass::QUIZ_FEEDBACK_MODE_REVEAL === tutor_utils()->get_quiz_option( $quiz_id_attempt, 'feedback_mode', '' );
+
+$instructor_mask = ! empty( $answer_obj->answer_two_gap_match ) ? (string) $answer_obj->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;
+
+$question_type = 'draw_image';
+
+$wrapper_id = 'tutor-draw-image-question-' . $question_id;
+$image_id = 'tutor-draw-image-bg-' . $question_id;
+$canvas_id = 'tutor-draw-image-canvas-' . $question_id;
+$hidden_input_id = 'tutor-draw-image-mask-' . $question_id;
+
+$field_name = ( $question_field_name_base ?? '' ) . '[answers][mask]';
+$register_rules = '';
+$required_message_js = isset( $required_message ) ? (string) $required_message : __( 'The answer for this question is required', 'tutor' );
+if ( $answer_is_required ) {
+ $register_rules = ", { required: '" . esc_js( $required_message_js ) . "' }";
+}
+$register_attr = "register('{$field_name}'{$register_rules})";
+
+/**
+ * Fires when the learning-area draw-image template is rendered; Tutor Pro
+ * hooks this to enqueue draw-image-question.js.
+ *
+ * @since 4.0.0
+ */
+do_action( 'tutor_enqueue_draw_image_question_script' );
+?>
+
+
+
+
+
; ?>)
+
+
+
+
+
+
+
+
+
; ?>)
+
"
+ role="presentation"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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..599a44a4a5
--- /dev/null
+++ b/templates/learning-area/quiz/questions/pin-image.php
@@ -0,0 +1,175 @@
+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/draw-image.php b/templates/shared/components/quiz/attempt-details/questions/draw-image.php
new file mode 100644
index 0000000000..cd30e73133
--- /dev/null
+++ b/templates/shared/components/quiz/attempt-details/questions/draw-image.php
@@ -0,0 +1,249 @@
+question_id, false );
+
+$instructor_answer_bg = null;
+
+$instructor_answer_mask = null;
+
+$ref_bg = '';
+
+$ref_mask_raw = '';
+
+if ( is_array( $draw_image_answers ) && ! empty( $draw_image_answers ) ) {
+ foreach ( $draw_image_answers as $answer_row ) {
+ if ( ! $instructor_answer_mask && ! empty( $answer_row->answer_two_gap_match ) ) {
+ $instructor_answer_mask = $answer_row;
+ }
+
+ if ( ! $instructor_answer_bg ) {
+ $maybe_bg_url = QuizModel::get_answer_image_url( $answer_row );
+ if ( $maybe_bg_url ) {
+ $instructor_answer_bg = $answer_row;
+ $ref_bg = $maybe_bg_url;
+ }
+ }
+
+ if ( $instructor_answer_bg && $instructor_answer_mask ) {
+ break;
+ }
+ }
+}
+
+$given_mask_raw = '';
+if ( $attempt_answer && isset( $attempt_answer->given_answer ) ) {
+ // Tutor Pro stores draw_image masks as a plain string (usually a local uploads URL)
+ // in `given_answer`. Keep this compatible with Pro.
+ $given_mask_raw = stripslashes( (string) $attempt_answer->given_answer );
+
+ $given_mask_raw = trim( $given_mask_raw );
+
+ // If mask was accidentally stored as serialized value, unwrap once.
+ if ( '' === $given_mask_raw ) {
+ $maybe_unserialized = maybe_unserialize( $attempt_answer->given_answer );
+ if ( is_string( $maybe_unserialized ) ) {
+ $given_mask_raw = trim( stripslashes( $maybe_unserialized ) );
+ }
+ }
+}
+
+$ref_mask_raw = $instructor_answer_mask && ! empty( $instructor_answer_mask->answer_two_gap_match ) ? trim( (string) $instructor_answer_mask->answer_two_gap_match ) : '';
+
+/**
+ * Normalize mask string for use in CSS mask-image url().
+ *
+ * @param string $mask Mask URL or data URI.
+ * @return string Escaped fragment for url("...") or empty.
+ */
+$mask_to_css_url = static function ( $mask ) {
+ $mask = trim( (string) $mask );
+ if ( '' === $mask ) {
+ return '';
+ }
+ // If it's a standard URL, normalize it for output.
+ if ( false !== wp_http_validate_url( $mask ) ) {
+ return esc_url_raw( $mask );
+ }
+
+ // Otherwise keep as-is (covers data URIs, relative paths, and other stored mask strings).
+ return $mask;
+};
+
+$given_mask_css = $mask_to_css_url( $given_mask_raw );
+
+$ref_mask_css = $mask_to_css_url( $ref_mask_raw );
+
+$has_correct_mask = '' !== $ref_mask_css;
+
+$has_student_mask = '' !== $given_mask_css;
+
+$has_student_drawn = '' !== trim( (string) $given_mask_raw );
+
+$has_bg = is_string( $ref_bg ) && '' !== trim( $ref_bg );
+
+$show_combined = $has_bg && ( $has_correct_mask || $has_student_drawn );
+
+$correct_mask_style = '';
+if ( $has_correct_mask ) {
+ $correct_mask_style = '--tutor-draw-mask-url: url("' . $ref_mask_css . '"); --tutor-draw-mask-bg: rgba(4, 201, 134, 0.28);';
+}
+$student_mask_style = '';
+if ( $has_student_drawn && $has_student_mask ) {
+ // Match the "correct" mask approach: fill color via --tutor-draw-mask-bg,
+ // while the border outline is handled in SCSS via drop-shadow.
+ // Inner tint should be faint (outline-only look). Derived alpha: 0.1608 * 0.16 ~= 0.0257.
+ $student_mask_style = '--tutor-draw-mask-url: url("' . $given_mask_css . '"); --tutor-draw-mask-bg: rgba(248, 0, 0, 0.0257);';
+}
+
+$draw_image_review_column = isset( $draw_image_review_column ) ? $draw_image_review_column : null;
+
+/**
+ * Dashboard quiz attempt details table: split "Given answer" vs "Correct answer" columns.
+ * Learning-area review keeps full combined layout (column null).
+ */
+if ( 'given' === $draw_image_review_column ) {
+ $given_bg_url = '';
+ if ( $instructor_answer_bg ) {
+ $given_bg_url = QuizModel::get_answer_image_url( $instructor_answer_bg );
+ }
+ if ( ! $given_bg_url && $ref_bg ) {
+ $given_bg_url = $ref_bg;
+ }
+ ?>
+
+ answer_two_gap_match ) ? trim( (string) $instructor_answer_mask->answer_two_gap_match ) : '';
+ $ref_mask_is_url = is_string( $ref_mask_for_correct ) && false !== wp_http_validate_url( $ref_mask_for_correct );
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
; ?>)
+
+
+
+
+
+
+
+
+
+
+
+
+
 ? esc_attr( $given_src_for_img ) : esc_url( $given_src_for_img ); ?>)
+
+
+
+
+
+
+
+
+
+
+
+
+
 ? esc_attr( $ref_src_for_img ) : esc_url( $ref_src_for_img ); ?>)
+
+
+
+
+
+
+
+
+
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..c384bf9622
--- /dev/null
+++ b/templates/shared/components/quiz/attempt-details/questions/pin-image.php
@@ -0,0 +1,102 @@
+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"
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/templates/shared/components/quiz/attempt-details/review-answers.php b/templates/shared/components/quiz/attempt-details/review-answers.php
index 34dfd7cd68..5523d42526 100644
--- a/templates/shared/components/quiz/attempt-details/review-answers.php
+++ b/templates/shared/components/quiz/attempt-details/review-answers.php
@@ -34,14 +34,26 @@
$question ) : ?>
question_type ?? '';
- $question_id = (int) ( $question->question_id ?? 0 );
- $is_dnd_review = in_array( $question_type, array( 'image_answering', 'ordering', 'matching', 'image_matching' ), true );
- $is_tf_review = 'true_false' === $question_type;
- $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;
- $attempt_answer = $attempt_answers_map[ $question_id ] ?? null;
+ $question_type = $question->question_type ?? '';
+
+ $question_id = (int) ( $question->question_id ?? 0 );
+
+ $is_dnd_review = in_array( $question_type, array( 'image_answering', 'ordering', 'matching', 'image_matching' ), true );
+
+ $is_tf_review = 'true_false' === $question_type;
+
+ $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_draw_image_review = 'draw_image' === $question_type;
+
+ $is_pin_review = 'pin_image' === $question_type;
+
+ $attempt_answer = $attempt_answers_map[ $question_id ] ?? null;
+
$question_template = '';
if ( $is_dnd_review ) {
@@ -54,6 +66,10 @@
$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.
*/