From 4dab000888764f7772a1ebf625855606d73f5a5f Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Tue, 10 Mar 2026 13:50:12 +0600 Subject: [PATCH 01/14] feat: add draw image threshold functionality to quiz questions - Introduced a new threshold selection for draw image questions, allowing instructors to set a required coverage percentage. - Updated the DrawImage component to include a FormSelectInput for threshold options. - Default threshold value is set to 70% if not specified. - Enhanced quiz data payload to include draw_image_threshold_percent. - Adjusted utility functions to handle the new threshold setting appropriately. --- .../curriculum/question-types/DrawImage.tsx | 49 ++++++++++++++++++- .../entries/course-builder/services/quiz.ts | 4 ++ .../fields/quiz/questions/FormDrawImage.tsx | 2 +- assets/src/js/v3/shared/utils/quiz.ts | 8 +++ assets/src/js/v3/shared/utils/types.ts | 2 + 5 files changed, 63 insertions(+), 2 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx index c668a4e282..d16e7bd80d 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx @@ -1,11 +1,14 @@ import { css } from '@emotion/react'; -import { useEffect } from 'react'; +import { __ } from '@wordpress/i18n'; +import { useEffect, useMemo } from 'react'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; import type { QuizForm } from '@CourseBuilderServices/quiz'; +import FormSelectInput from '@TutorShared/components/fields/FormSelectInput'; import FormDrawImage from '@TutorShared/components/fields/quiz/questions/FormDrawImage'; import { spacing } from '@TutorShared/config/styles'; +import { calculateQuizDataStatus } from '@TutorShared/utils/quiz'; import { styleUtils } from '@TutorShared/utils/style-utils'; import { QuizDataStatus, type QuizQuestionOption } from '@TutorShared/utils/types'; import { nanoid } from '@TutorShared/utils/util'; @@ -13,14 +16,27 @@ import { nanoid } from '@TutorShared/utils/util'; const DrawImage = () => { const form = useFormContext(); const { activeQuestionId, activeQuestionIndex, validationError, setValidationError } = useQuizModalContext(); + const activeQuestionDataStatus = + form.watch(`questions.${activeQuestionIndex}._data_status`) ?? QuizDataStatus.NO_CHANGE; const answersPath = `questions.${activeQuestionIndex}.question_answers` as 'questions.0.question_answers'; + const thresholdPath = + `questions.${activeQuestionIndex}.question_settings.draw_image_threshold_percent` as 'questions.0.question_settings.draw_image_threshold_percent'; const { fields: optionsFields } = useFieldArray({ control: form.control, name: answersPath, }); + const thresholdOptions = useMemo( + () => + [40, 50, 60, 70, 80, 90, 100].map((value) => ({ + label: `${value}%`, + value, + })), + [], + ); + // Ensure there is always a single option for this question type. useEffect(() => { if (!activeQuestionId) { @@ -46,6 +62,14 @@ const DrawImage = () => { form.setValue(answersPath, [baseAnswer]); }, [activeQuestionId, optionsFields.length, answersPath, form]); + // Default threshold for draw-image questions if not set. + useEffect(() => { + const currentValue = form.getValues(thresholdPath); + if (currentValue === undefined || currentValue === null || Number.isNaN(Number(currentValue))) { + form.setValue(thresholdPath, 70); + } + }, [form, thresholdPath]); + // Only render Controller when the value exists to ensure field.value is always defined if (optionsFields.length === 0) { return null; @@ -53,6 +77,28 @@ const DrawImage = () => { return (
+ ( + { + controllerProps.field.onChange(option.value); + if (calculateQuizDataStatus(activeQuestionDataStatus, QuizDataStatus.UPDATE)) { + form.setValue( + `questions.${activeQuestionIndex}._data_status`, + calculateQuizDataStatus(activeQuestionDataStatus, QuizDataStatus.UPDATE) as QuizDataStatus, + ); + } + }} + /> + )} + /> + diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index a95889524a..637c712cac 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -207,7 +207,7 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { drawInstanceRef.current.destroy(); drawInstanceRef.current = null; } - const brushSize = api.DEFAULT_BRUSH_SIZE ?? 15; + const brushSize = 1; const instance = api.init({ image: img, canvas, 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..393ef15756 100644 --- a/assets/src/js/v3/shared/utils/types.ts +++ b/assets/src/js/v3/shared/utils/types.ts @@ -331,6 +331,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 +346,7 @@ export interface QuizQuestionsForPayload extends Omit Date: Tue, 10 Mar 2026 15:51:40 +0600 Subject: [PATCH 02/14] refactor: improve DrawImage component structure and integrate precision control - Refactored the DrawImage component to enhance readability and maintainability. - Integrated precision control for draw image questions, allowing instructors to set a precision level. - Updated the FormDrawImage component to accept a new `precisionControl` prop for better modularity. - Simplified the rendering logic for threshold selection and draw image functionality. --- .../curriculum/question-types/DrawImage.tsx | 64 ++-- .../fields/quiz/questions/FormDrawImage.tsx | 277 ++++++------------ 2 files changed, 123 insertions(+), 218 deletions(-) diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx index d16e7bd80d..c60d057f20 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/DrawImage.tsx @@ -5,8 +5,8 @@ import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; import type { QuizForm } from '@CourseBuilderServices/quiz'; -import FormSelectInput from '@TutorShared/components/fields/FormSelectInput'; import FormDrawImage from '@TutorShared/components/fields/quiz/questions/FormDrawImage'; +import FormSelectInput from '@TutorShared/components/fields/FormSelectInput'; import { spacing } from '@TutorShared/config/styles'; import { calculateQuizDataStatus } from '@TutorShared/utils/quiz'; import { styleUtils } from '@TutorShared/utils/style-utils'; @@ -78,37 +78,41 @@ const DrawImage = () => { return (
( - { - controllerProps.field.onChange(option.value); - if (calculateQuizDataStatus(activeQuestionDataStatus, QuizDataStatus.UPDATE)) { - form.setValue( - `questions.${activeQuestionIndex}._data_status`, - calculateQuizDataStatus(activeQuestionDataStatus, QuizDataStatus.UPDATE) as QuizDataStatus, - ); - } - }} - /> - )} - /> - - ( - ( + ( + { + thresholdControllerProps.field.onChange(option.value); + if (calculateQuizDataStatus(activeQuestionDataStatus, QuizDataStatus.UPDATE)) { + form.setValue( + `questions.${activeQuestionIndex}._data_status`, + calculateQuizDataStatus(activeQuestionDataStatus, QuizDataStatus.UPDATE) as QuizDataStatus, + ); + } + }} + /> + } + /> + )} /> )} /> diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index 637c712cac..be39af84be 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -1,12 +1,22 @@ import { css } from '@emotion/react'; import { __ } from '@wordpress/i18n'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef } 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 { + borderRadius, + Breakpoint, + colorTokens, + spacing, + fontFamily, + fontSize, + fontWeight, + lineHeight, + letterSpacing, +} from '@TutorShared/config/styles'; import { typography } from '@TutorShared/config/typography'; import Show from '@TutorShared/controls/Show'; import useWPMedia from '@TutorShared/hooks/useWpMedia'; @@ -34,13 +44,12 @@ interface FormDrawImageProps extends FormControllerProps { type: QuizValidationErrorType; } | null> >; + precisionControl?: React.ReactNode; } -const FormDrawImage = ({ field }: FormDrawImageProps) => { +const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { const option = field.value; - const [isDrawModeActive, setIsDrawModeActive] = useState(false); - const imageRef = useRef(null); const canvasRef = useRef(null); const drawInstanceRef = useRef<{ destroy: () => void } | null>(null); @@ -52,56 +61,6 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { [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', @@ -125,7 +84,6 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { drawInstanceRef.current.destroy(); drawInstanceRef.current = null; } - setIsDrawModeActive(false); } }, initialFiles: option?.image_id @@ -137,64 +95,9 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { : null, }); - /* - * Display-only canvas sync (when not in draw mode): we use three separate useEffects - * so each one handles a single concern and its own cleanup: - * 1) Sync immediately when deps change (image URL, mask, draw mode). - * 2) Sync when the fires 'load' (e.g. after src change or first load). - * 3) Sync when the container is resized (ResizeObserver). - * React runs them in declaration order after commit; merging into one effect would - * mix three different triggers and cleanups (addEventListener, ResizeObserver) in one place. - */ - useEffect(() => { - if (isDrawModeActive) { - return; - } - syncCanvasDisplay(option?.answer_two_gap_match || undefined); - }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); - useEffect(() => { - if (isDrawModeActive) { - return; - } - const img = imageRef.current; - if (!img) { - return; - } - const handleLoad = () => { - syncCanvasDisplay(option?.answer_two_gap_match || undefined); - }; - img.addEventListener('load', handleLoad); - return () => { - img.removeEventListener('load', handleLoad); - }; - }, [isDrawModeActive, option?.answer_two_gap_match, syncCanvasDisplay]); - - useEffect(() => { - if (isDrawModeActive) { - return; - } - const img = imageRef.current; - const canvas = canvasRef.current; - if (!img || !canvas) { - return; - } - const container = img.parentElement; - if (!container) { - return; - } - const resizeObserver = new ResizeObserver(() => { - syncCanvasDisplay(option?.answer_two_gap_match || undefined); - }); - resizeObserver.observe(container); - return () => { - resizeObserver.disconnect(); - }; - }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); - - // Wire to shared draw-on-image module when draw mode is active (Tutor Pro). - useEffect(() => { - if (!isDrawModeActive || !option?.image_url) { + const imageUrl = option?.image_url; + if (!imageUrl) { return; } const img = imageRef.current; @@ -203,24 +106,34 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { if (!img || !canvas || !api?.init) { return; } + // Only initialize once per mount; keep the same instance while the instructor is drawing. if (drawInstanceRef.current) { - drawInstanceRef.current.destroy(); - drawInstanceRef.current = null; + return; } const brushSize = 1; + const currentOption: QuizQuestionOption | null = option ?? null; const instance = api.init({ image: img, canvas, brushSize, strokeStyle: INSTRUCTOR_STROKE_STYLE, - initialMaskUrl: option.answer_two_gap_match || undefined, + initialMaskUrl: currentOption?.answer_two_gap_match || undefined, + onMaskChange: (maskValue: string) => { + if (!currentOption) { + return; + } + updateOption({ + ...currentOption, + ...(calculateQuizDataStatus(currentOption._data_status, QuizDataStatus.UPDATE) && { + _data_status: calculateQuizDataStatus(currentOption._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, + }), + answer_two_gap_match: maskValue, + is_saved: true, + }); + }, }); drawInstanceRef.current = instance; - return () => { - instance.destroy(); - drawInstanceRef.current = null; - }; - }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match]); + }, [option, updateOption]); // Cleanup shared instance on unmount. useEffect(() => { @@ -236,35 +149,6 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { return 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(); @@ -286,11 +170,6 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { is_saved: true, }; updateOption(updated); - setIsDrawModeActive(false); - }; - - const handleDraw = () => { - setIsDrawModeActive(true); }; const clearImage = () => { @@ -298,8 +177,6 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { drawInstanceRef.current.destroy(); drawInstanceRef.current = null; } - setIsDrawModeActive(false); - const updated: QuizQuestionOption = { ...option, ...(calculateQuizDataStatus(option._data_status, QuizDataStatus.UPDATE) && { @@ -344,7 +221,7 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => {
- {/* Section 2: Mark the correct area — single reference image + drawing canvas; Save / Clear / Draw buttons */} + {/* Section 2: Mark the correct area — single reference image + interactive drawing canvas */}
@@ -354,6 +231,15 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { {__('Mark the correct area', __TUTOR_TEXT_DOMAIN__)} +
{ /> +
+ +
-
- - - -
-

- {__('Use the brush to draw on the image, then click Save to store the answer zone.', __TUTOR_TEXT_DOMAIN__)} -

+ {precisionControl &&
{precisionControl}
}

{__('Answer zone saved. Students will be graded against this area.', __TUTOR_TEXT_DOMAIN__)} @@ -493,11 +354,51 @@ const styles = { pointer-events: auto; cursor: crosshair; `, + drawBadge: css` + position: absolute; + top: ${spacing[12]}; + right: ${spacing[12]}; + width: 32px; + height: 32px; + border-radius: 999px; + background: ${colorTokens.surface.tutor}; + border: 1px solid ${colorTokens.stroke.border}; + ${styleUtils.display.flex('row')}; + align-items: center; + justify-content: center; + color: ${colorTokens.text.subdued}; + box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16); + `, actionsRow: css` ${styleUtils.display.flex('row')}; gap: ${spacing[12]}; flex-wrap: wrap; `, + clearButton: css` + width: 94px; + height: 32px; + border-radius: 6px; + border: none; + box-shadow: none; + padding: 4px 12px; + gap: 4px; + background: ${colorTokens.action.secondary.default}; + color: ${colorTokens.text.brand}; + font-family: ${fontFamily.sfProDisplay}; + font-weight: ${fontWeight.medium}; + font-size: ${fontSize[13]}; + line-height: ${lineHeight[20]}; + letter-spacing: ${letterSpacing.normal}; + text-align: center; + &:hover { + background: ${colorTokens.action.secondary.hover}; + border: none; + box-shadow: none; + } + `, + clearIcon: css` + color: ${colorTokens.text.brand}; + `, brushHint: css` ${typography.caption()}; color: ${colorTokens.text.subdued}; From 8af2a1afb02cc6e2fe69a459f3f48e72f73a7e07 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 30 Mar 2026 13:29:12 +0600 Subject: [PATCH 03/14] Implement draw on image functionality for quiz attempts, including new template for rendering and review. Update TypeScript definitions and React component to support drawing features and state management. Add SCSS styles for review display of correct and student masks. --- assets/core/ts/declaration.d.ts | 5 +- .../fields/quiz/questions/FormDrawImage.tsx | 366 +++++++++++++++--- .../components/_quiz-attempt-details.scss | 86 ++++ .../quiz/questions/draw-image.php | 187 +++++++++ .../attempt-details/questions/draw-image.php | 199 ++++++++++ .../quiz/attempt-details/review-answers.php | 28 +- 6 files changed, 812 insertions(+), 59 deletions(-) create mode 100644 templates/learning-area/quiz/questions/draw-image.php create mode 100644 templates/shared/components/quiz/attempt-details/questions/draw-image.php diff --git a/assets/core/ts/declaration.d.ts b/assets/core/ts/declaration.d.ts index 57fab0c735..27affd902b 100644 --- a/assets/core/ts/declaration.d.ts +++ b/assets/core/ts/declaration.d.ts @@ -66,12 +66,15 @@ declare global { }; drawOnImage?: { init: (options: { - image: HTMLImageElement; + image: HTMLImageElement | null; canvas: HTMLCanvasElement; + hiddenInput?: HTMLInputElement | null; brushSize?: number; strokeStyle?: string; initialMaskUrl?: string; onMaskChange?: (value: string) => void; + interactionRoot?: HTMLElement | null; + activateOnHover?: boolean; }) => { destroy: () => void }; DEFAULT_BRUSH_SIZE?: number; DEFAULT_STROKE_STYLE?: string; diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index be39af84be..ee43249289 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/react'; import { __ } from '@wordpress/i18n'; -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import Button from '@TutorShared/atoms/Button'; import ImageInput from '@TutorShared/atoms/ImageInput'; @@ -30,7 +30,11 @@ import { type QuizValidationErrorType, } from '@TutorShared/utils/types'; -const INSTRUCTOR_STROKE_STYLE = 'rgba(255, 0, 0, 0.9)'; +/** Same lasso visuals as FormPinImage (feat/quiz-type-pin-image) for instructor region marking. */ +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 FormDrawImageProps extends FormControllerProps { questionId: ID; @@ -50,9 +54,14 @@ interface FormDrawImageProps extends FormControllerProps { const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { const option = field.value; + const [isDrawModeActive, setIsDrawModeActive] = useState(false); + const imageRef = useRef(null); const canvasRef = useRef(null); const drawInstanceRef = useRef<{ destroy: () => void } | null>(null); + const isLassoDrawingRef = useRef(false); + const lassoPointsRef = useRef>([]); + const baseImageDataRef = useRef(null); const updateOption = useCallback( (updated: QuizQuestionOption) => { @@ -61,6 +70,56 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { [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', @@ -68,7 +127,6 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { 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) && { @@ -79,11 +137,11 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { 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 @@ -96,46 +154,199 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { }); useEffect(() => { - const imageUrl = option?.image_url; - if (!imageUrl) { + 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; - const api = typeof window !== 'undefined' ? window.TutorCore?.drawOnImage : undefined; - if (!img || !canvas || !api?.init) { + if (!img || !canvas) { return; } - // Only initialize once per mount; keep the same instance while the instructor is drawing. - if (drawInstanceRef.current) { + 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]); + + // Draw-image instructor UI: same lasso polygon flow as FormPinImage (feat/quiz-type-pin-image). + useEffect(() => { + if (!isDrawModeActive || !option?.image_url) { return; } - const brushSize = 1; - const currentOption: QuizQuestionOption | null = option ?? null; - const instance = api.init({ - image: img, - canvas, - brushSize, - strokeStyle: INSTRUCTOR_STROKE_STYLE, - initialMaskUrl: currentOption?.answer_two_gap_match || undefined, - onMaskChange: (maskValue: string) => { - if (!currentOption) { - 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); } - updateOption({ - ...currentOption, - ...(calculateQuizDataStatus(currentOption._data_status, QuizDataStatus.UPDATE) && { - _data_status: calculateQuizDataStatus(currentOption._data_status, QuizDataStatus.UPDATE) as QuizDataStatus, - }), - answer_two_gap_match: maskValue, - is_saved: true, - }); + }); + 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; - }, [option, updateOption]); - // Cleanup shared instance on unmount. + return () => { + instance.destroy(); + drawInstanceRef.current = null; + }; + }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match]); + useEffect(() => { return () => { if (drawInstanceRef.current) { @@ -145,11 +356,34 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { }; }, []); - if (!option) { - return 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; @@ -172,11 +406,27 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { 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) && { @@ -196,9 +446,34 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { } }; + 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 in Mark the correct area */}
{
- {/* Section 2: Mark the correct area — single reference image + interactive drawing canvas */}
@@ -241,7 +515,7 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { {__('Clear', __TUTOR_TEXT_DOMAIN__)}
-
+
{ />
@@ -345,6 +619,7 @@ const styles = { position: absolute; top: 0; left: 0; + z-index: 1; `, canvasIdleMode: css` pointer-events: none; @@ -358,6 +633,7 @@ const styles = { position: absolute; top: ${spacing[12]}; right: ${spacing[12]}; + z-index: 2; width: 32px; height: 32px; border-radius: 999px; @@ -369,11 +645,6 @@ const styles = { color: ${colorTokens.text.subdued}; box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16); `, - actionsRow: css` - ${styleUtils.display.flex('row')}; - gap: ${spacing[12]}; - flex-wrap: wrap; - `, clearButton: css` width: 94px; height: 32px; @@ -399,11 +670,6 @@ const styles = { clearIcon: css` color: ${colorTokens.text.brand}; `, - brushHint: css` - ${typography.caption()}; - color: ${colorTokens.text.subdued}; - margin: 0; - `, savedHint: css` ${typography.caption()}; color: ${colorTokens.text.success}; diff --git a/assets/src/scss/frontend/components/_quiz-attempt-details.scss b/assets/src/scss/frontend/components/_quiz-attempt-details.scss index d0702339f7..2de466f80a 100644 --- a/assets/src/scss/frontend/components/_quiz-attempt-details.scss +++ b/assets/src/scss/frontend/components/_quiz-attempt-details.scss @@ -324,3 +324,89 @@ margin-bottom: $tutor-spacing-6; } } + +// Draw on Image — attempt review: correct (green) + student (red) masks on one background. +.tutor-quiz-draw-image-review { + .tutor-draw-image-review-legend { + @include tutor-flex(row); + flex-wrap: wrap; + gap: $tutor-spacing-6; + margin: 0; + padding: 0; + list-style: none; + } + + .tutor-draw-image-review-legend__item { + @include tutor-flex(row, center); + gap: $tutor-spacing-3; + } + + .tutor-draw-image-review-swatch { + flex-shrink: 0; + width: 12px; + height: 12px; + border-radius: 50%; + + &--correct { + background-color: rgba(4, 201, 134, 0.35); + box-shadow: 0 0 0 1px #53b96a; + } + + &--student { + background-color: rgba(233, 62, 62, 0.35); + box-shadow: 0 0 0 1px #e53935; + } + } + + .tutor-draw-image-review-inner { + position: relative; + display: inline-block; + overflow: hidden; + max-width: 100%; + } + + .tutor-draw-image-review-inner .tutor-draw-image-bg { + display: block; + max-width: 100%; + height: auto; + vertical-align: top; + } + + .tutor-draw-image-review-mask { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + pointer-events: none; + background: var(--tutor-draw-mask-bg); + -webkit-mask-image: var(--tutor-draw-mask-url); + -webkit-mask-repeat: no-repeat; + -webkit-mask-size: 100% 100%; + mask-image: var(--tutor-draw-mask-url); + mask-repeat: no-repeat; + mask-size: 100% 100%; + } + + .tutor-draw-image-review-mask--correct { + filter: + drop-shadow(1px 0 0 #53b96a) + drop-shadow(-1px 0 0 #53b96a) + drop-shadow(0 1px 0 #53b96a) + drop-shadow(0 -1px 0 #53b96a); + } + + .tutor-draw-image-review-mask--student { + filter: + drop-shadow(1px 0 0 #e53935) + drop-shadow(-1px 0 0 #e53935) + drop-shadow(0 1px 0 #e53935) + drop-shadow(0 -1px 0 #e53935); + } + + .tutor-draw-image-review-fallback .tutor-draw-image-single { + display: block; + max-width: 100%; + height: auto; + } +} 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..59e788be43 --- /dev/null +++ b/templates/learning-area/quiz/questions/draw-image.php @@ -0,0 +1,187 @@ + 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 = 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' ); +?> + +
+ +
+ <?php esc_attr_e( 'Draw on image question', 'tutor' ); ?> + +
+ + + + +

+ +

+ + + + +

+ +

+
+ +
+ + 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..cde7f7baf1 --- /dev/null +++ b/templates/shared/components/quiz/attempt-details/questions/draw-image.php @@ -0,0 +1,199 @@ +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 ) { + $student_mask_style = '--tutor-draw-mask-url: url("' . $given_mask_css . '"); --tutor-draw-mask-bg: rgba(233, 62, 62, 0.2);'; +} + +?> + +
+ + +

+ +

+ + +
    + +
  • + + +
  • + + +
  • + + +
  • + +
+ +
+ + + + + + + +
+ + +
+

+ +

+
+ + +
+
+ +

+ +

+ + + +
+

+ +

+
+ + +
+
+ + +

+ +

+ +
diff --git a/templates/shared/components/quiz/attempt-details/review-answers.php b/templates/shared/components/quiz/attempt-details/review-answers.php index 34dfd7cd68..0469a38091 100644 --- a/templates/shared/components/quiz/attempt-details/review-answers.php +++ b/templates/shared/components/quiz/attempt-details/review-answers.php @@ -34,14 +34,24 @@
$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; + + $attempt_answer = $attempt_answers_map[ $question_id ] ?? null; + $question_template = ''; if ( $is_dnd_review ) { @@ -54,6 +64,8 @@ $question_template = 'open-ended'; } elseif ( $is_fib_review ) { $question_template = 'fill-in-the-blank'; + } elseif ( $is_draw_image_review ) { + $question_template = 'draw-image'; } ?> From 6e8b02d2697ba8d544f9bed75c4607abfc2ed181 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 30 Mar 2026 13:52:11 +0600 Subject: [PATCH 04/14] Refactor draw-image question template to improve quiz attempt handling. Update quiz ID retrieval logic for better type safety and remove unused inline styles for cleaner code. --- .../quiz/questions/draw-image.php | 55 +------------------ 1 file changed, 1 insertion(+), 54 deletions(-) diff --git a/templates/learning-area/quiz/questions/draw-image.php b/templates/learning-area/quiz/questions/draw-image.php index 59e788be43..f9a2acadfe 100644 --- a/templates/learning-area/quiz/questions/draw-image.php +++ b/templates/learning-area/quiz/questions/draw-image.php @@ -34,7 +34,7 @@ $bg_image_url = QuizModel::get_answer_image_url( $answer_obj ); -$quiz_id_attempt = isset( $tutor_is_started_quiz->quiz_id ) ? (int) $tutor_is_started_quiz->quiz_id : 0; +$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 : ''; @@ -132,56 +132,3 @@ class="tutor-quiz-questions-error" x-text="errors?.['']?.message" >
- From 4347fee8c94261134589bd8fff72cebb6e29ab12 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 30 Mar 2026 14:07:06 +0600 Subject: [PATCH 05/14] Remove unused elements from draw-image question template to streamline quiz attempt display. Enhance clarity by eliminating redundant markup related to answer zones and student drawings. --- .../attempt-details/questions/draw-image.php | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/templates/shared/components/quiz/attempt-details/questions/draw-image.php b/templates/shared/components/quiz/attempt-details/questions/draw-image.php index cde7f7baf1..aa966df5b5 100644 --- a/templates/shared/components/quiz/attempt-details/questions/draw-image.php +++ b/templates/shared/components/quiz/attempt-details/questions/draw-image.php @@ -118,22 +118,6 @@

- -
    - -
  • - - -
  • - - -
  • - - -
  • - -
-
@@ -154,9 +138,6 @@ class="tutor-draw-image-review-mask tutor-draw-image-review-mask--student"
-

- -

-

- -

Date: Mon, 30 Mar 2026 15:38:06 +0600 Subject: [PATCH 06/14] Refactor SCSS and PHP for quiz attempt details. Update drop-shadow colors for student mask to a more vibrant red and adjust mask background opacity for better visibility. Ensure student mask style aligns with the correct mask approach for consistency in quiz display. --- .../scss/frontend/components/_quiz-attempt-details.scss | 9 ++++----- .../quiz/attempt-details/questions/draw-image.php | 7 +++++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/assets/src/scss/frontend/components/_quiz-attempt-details.scss b/assets/src/scss/frontend/components/_quiz-attempt-details.scss index 2de466f80a..6e6c230e33 100644 --- a/assets/src/scss/frontend/components/_quiz-attempt-details.scss +++ b/assets/src/scss/frontend/components/_quiz-attempt-details.scss @@ -369,7 +369,6 @@ display: block; max-width: 100%; height: auto; - vertical-align: top; } .tutor-draw-image-review-mask { @@ -398,10 +397,10 @@ .tutor-draw-image-review-mask--student { filter: - drop-shadow(1px 0 0 #e53935) - drop-shadow(-1px 0 0 #e53935) - drop-shadow(0 1px 0 #e53935) - drop-shadow(0 -1px 0 #e53935); + drop-shadow(1px 0 0 #F80000) + drop-shadow(-1px 0 0 #F80000) + drop-shadow(0 1px 0 #F80000) + drop-shadow(0 -1px 0 #F80000); } .tutor-draw-image-review-fallback .tutor-draw-image-single { diff --git a/templates/shared/components/quiz/attempt-details/questions/draw-image.php b/templates/shared/components/quiz/attempt-details/questions/draw-image.php index aa966df5b5..37d8c01325 100644 --- a/templates/shared/components/quiz/attempt-details/questions/draw-image.php +++ b/templates/shared/components/quiz/attempt-details/questions/draw-image.php @@ -106,7 +106,10 @@ } $student_mask_style = ''; if ( $has_student_drawn && $has_student_mask ) { - $student_mask_style = '--tutor-draw-mask-url: url("' . $given_mask_css . '"); --tutor-draw-mask-bg: rgba(233, 62, 62, 0.2);'; + // 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);'; } ?> @@ -127,7 +130,7 @@ class="tutor-draw-image-review-mask tutor-draw-image-review-mask--correct" role="presentation" > - + Date: Mon, 30 Mar 2026 16:29:01 +0600 Subject: [PATCH 07/14] Enhance FormDrawImage component to conditionally render image upload and display. Implement logic to show uploaded image or upload prompt based on image URL presence. Update styles for better responsiveness and user interaction. --- .../fields/quiz/questions/FormDrawImage.tsx | 76 +++++++++++++------ 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index ee43249289..d9fa7ebafc 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -474,27 +474,50 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { return (
-
-
- + +
+
+ +
-
+ + + +
+
+ {__('Background { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openMediaLibrary(); + } + }} + /> +
+
+
@@ -527,9 +550,6 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { css={[styles.canvas, isDrawModeActive ? styles.canvasDrawMode : styles.canvasIdleMode]} aria-label={__('Draw a lasso around the correct answer area', __TUTOR_TEXT_DOMAIN__)} /> -
- -
{precisionControl &&
{precisionControl}
} @@ -578,6 +598,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; From d1cf32cbd5203b92f2cb0b3b75a46faa8e85c0a5 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Mon, 30 Mar 2026 16:41:59 +0600 Subject: [PATCH 08/14] Refactor clear button in FormDrawImage component for improved styling and accessibility. Replace Button component with a native button element, enhancing the user experience and aligning with design tokens for consistent styling. --- .../fields/quiz/questions/FormDrawImage.tsx | 50 ++++++------------- 1 file changed, 14 insertions(+), 36 deletions(-) diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index d9fa7ebafc..f2d50617c5 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -2,21 +2,10 @@ 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, - fontFamily, - fontSize, - fontWeight, - lineHeight, - letterSpacing, -} from '@TutorShared/config/styles'; +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'; @@ -528,15 +517,10 @@ const FormDrawImage = ({ field, precisionControl }: FormDrawImageProps) => { {__('Mark the correct area', __TUTOR_TEXT_DOMAIN__)} - +
Date: Mon, 30 Mar 2026 16:50:37 +0600 Subject: [PATCH 09/14] Remove outdated comment from FormDrawImage component to enhance code clarity and maintainability. --- .../v3/shared/components/fields/quiz/questions/FormDrawImage.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx index f2d50617c5..34c9e5d1e2 100644 --- a/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx +++ b/assets/src/js/v3/shared/components/fields/quiz/questions/FormDrawImage.tsx @@ -19,7 +19,6 @@ import { type QuizValidationErrorType, } from '@TutorShared/utils/types'; -/** Same lasso visuals as FormPinImage (feat/quiz-type-pin-image) for instructor region marking. */ 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]; From 6d51627c23692ec8b27ee36b46e850873b03b10c Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 2 Apr 2026 11:17:53 +0600 Subject: [PATCH 10/14] Add is_legacy_learning_mode support across various components and classes - Introduced `is_legacy_learning_mode` boolean in TypeScript declarations and configurations. - Updated `QuestionList` component to conditionally filter question types based on legacy mode. - Enhanced `Assets` and `QuizBuilder` classes to utilize the new legacy mode setting. - Implemented a method in `Utils` to check if legacy learning mode is active. - Updated draw-image question template to handle legacy mode restrictions for question types. --- assets/core/ts/declaration.d.ts | 1 + assets/src/js/v3/@types/index.d.ts | 1 + .../components/curriculum/QuestionList.tsx | 8 ++- assets/src/js/v3/shared/config/config.ts | 1 + classes/Assets.php | 1 + classes/QuizBuilder.php | 6 ++ classes/Utils.php | 11 +++ .../attempt-details/questions/draw-image.php | 69 +++++++++++++++++++ 8 files changed, 97 insertions(+), 1 deletion(-) diff --git a/assets/core/ts/declaration.d.ts b/assets/core/ts/declaration.d.ts index 27affd902b..a7dbde4902 100644 --- a/assets/core/ts/declaration.d.ts +++ b/assets/core/ts/declaration.d.ts @@ -111,6 +111,7 @@ declare global { ajaxurl?: string; tutor_url?: string; wp_date_format?: string; + is_legacy_learning_mode?: boolean; }; } } diff --git a/assets/src/js/v3/@types/index.d.ts b/assets/src/js/v3/@types/index.d.ts index 480c101e50..793c82efe5 100644 --- a/assets/src/js/v3/@types/index.d.ts +++ b/assets/src/js/v3/@types/index.d.ts @@ -126,6 +126,7 @@ declare global { }[]; kids_icons_registry: string[]; is_kids_mode: boolean; + is_legacy_learning_mode: boolean; current_user: { data: { id: string; 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 062ddc8877..da1aab69b3 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 @@ -115,6 +115,12 @@ const questionTypeOptions: { 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; + }, []); const [activeSortId, setActiveSortId] = useState(null); const [isOpen, setIsOpen] = useState(false); const questionListRef = useRef(null); @@ -446,7 +452,7 @@ const QuestionList = ({ isEditing }: { isEditing: boolean }) => { >
{__('Select Question Type', 'tutor')} - {questionTypeOptions.map((option) => ( + {questionTypeOptionsForUi.map((option) => ( 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/QuizBuilder.php b/classes/QuizBuilder.php index fa8433f502..089f7241ad 100644 --- a/classes/QuizBuilder.php +++ b/classes/QuizBuilder.php @@ -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,10 @@ 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 ); + } $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 802810a52e..bb68bbf4d9 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -11073,4 +11073,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/templates/shared/components/quiz/attempt-details/questions/draw-image.php b/templates/shared/components/quiz/attempt-details/questions/draw-image.php index 37d8c01325..cd30e73133 100644 --- a/templates/shared/components/quiz/attempt-details/questions/draw-image.php +++ b/templates/shared/components/quiz/attempt-details/questions/draw-image.php @@ -112,6 +112,75 @@ $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 ); + ?> +
+ + +
+

+ +

+ +
+ + +
+ + + +
+ +
+
From 3094fc6b29bcac7f55445f690d008f82543454eb Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Thu, 2 Apr 2026 12:10:44 +0600 Subject: [PATCH 11/14] Enhance QuestionList component by enabling 'Draw on Image' quiz type - Added 'Draw on Image' option to the question type options in the QuestionList component. - Updated the component to include the corresponding label, value, icon, and pro status for the new question type. --- .../components/curriculum/QuestionList.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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 432309ad9e..f282e90b25 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx @@ -103,13 +103,12 @@ const questionTypeOptions: { icon: 'quizOrdering', isPro: true, }, - // { - // label: __('Draw on Image', 'tutor'), - // value: 'draw_image', - // // TODO: icon is not final. - // icon: 'quizImageAnswer', - // isPro: true, - // }, + { + label: __('Draw on Image', 'tutor'), + value: 'draw_image', + icon: 'quizImageAnswer', + isPro: true, + }, ]; const isTutorPro = !!tutorConfig.tutor_pro_url; From 09f0f70fb3fd61f419531fa456ebea386757bfc2 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 3 Apr 2026 10:51:30 +0600 Subject: [PATCH 12/14] Implement template loading control for add-on only question partials in Quiz class - Added a filter to skip loading templates that are not part of the core Tutor LMS, specifically for 'Draw on Image' question types. - Removed deprecated 'draw-image' templates from the learning area and attempt details to streamline the codebase. --- classes/Quiz.php | 39 +++ .../quiz/questions/draw-image.php | 134 ---------- .../attempt-details/questions/draw-image.php | 249 ------------------ 3 files changed, 39 insertions(+), 383 deletions(-) delete mode 100644 templates/learning-area/quiz/questions/draw-image.php delete mode 100644 templates/shared/components/quiz/attempt-details/questions/draw-image.php diff --git a/classes/Quiz.php b/classes/Quiz.php index 64c1d95922..29796ef77a 100644 --- a/classes/Quiz.php +++ b/classes/Quiz.php @@ -168,6 +168,45 @@ public function __construct( $register_hooks = true ) { // Add quiz title as nav item & render single content on the learning area. add_action( "tutor_learning_area_nav_item_{$this->post_type}", array( $this, 'render_nav_item' ), 10, 2 ); add_action( "tutor_single_content_{$this->post_type}", array( $this, 'render_single_content' ) ); + + /** + * Slugs listed in tutor_quiz_templates_not_in_core have no file under wp-content/plugins/tutor/templates/. + * Without this, tutor_load_template() still runs from generic quiz templates and tutor_get_template() + * prints "The file you are trying to load does not exist…". Returning false here exits before that lookup. + * Add-ons ship their own files and load them outside this path (e.g. direct include from the add-on). + * + * @since 4.0.0 + */ + add_filter( 'should_tutor_load_template', array( $this, 'skip_addon_only_question_partials' ), 5, 3 ); + } + + /** + * Skip loading templates that are not packaged with core Tutor LMS. + * + * @since 4.0.0 + * + * @param bool $load Whether to load the template. + * @param string $template Template name in dot notation. + * @param array $variables Template variables. + * + * @return bool + */ + public function skip_addon_only_question_partials( $load, $template, $variables ) { + $addons_only = apply_filters( + 'tutor_quiz_templates_not_in_core', + array( + 'learning-area.quiz.questions.draw-image', + 'shared.components.quiz.attempt-details.questions.draw-image', + ), + $template, + $variables + ); + + if ( in_array( $template, $addons_only, true ) ) { + return false; + } + + return $load; } /** diff --git a/templates/learning-area/quiz/questions/draw-image.php b/templates/learning-area/quiz/questions/draw-image.php deleted file mode 100644 index f9a2acadfe..0000000000 --- a/templates/learning-area/quiz/questions/draw-image.php +++ /dev/null @@ -1,134 +0,0 @@ - 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' ); -?> - -
- -
- <?php esc_attr_e( 'Draw on image question', 'tutor' ); ?> - -
- - - - -

- -

- - - - -

- -

-
- -
- diff --git a/templates/shared/components/quiz/attempt-details/questions/draw-image.php b/templates/shared/components/quiz/attempt-details/questions/draw-image.php deleted file mode 100644 index cd30e73133..0000000000 --- a/templates/shared/components/quiz/attempt-details/questions/draw-image.php +++ /dev/null @@ -1,249 +0,0 @@ -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 ); - ?> -
- - -
-

- -

- -
- - -
- - - -
- -
- - -
- - -

- -

- -
- - - - - - - -
- - -
-
- - -
-
- -

- -

- - - -
-
- - -
-
- - -

- -

- -
From 7c8d2f7c25f50c3c6daf0aa2b8b6e1ba0ee82b97 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 3 Apr 2026 11:09:17 +0600 Subject: [PATCH 13/14] Refactor quiz utility and remove unused SCSS for draw-image review - Simplified the `convertedQuestion` function in `quiz.ts` by removing unnecessary type assertions for `draw_image_threshold_percent`. - Deleted unused SCSS styles related to the 'Draw on Image' review, streamlining the codebase and improving maintainability. --- assets/src/js/v3/shared/utils/quiz.ts | 6 +- .../components/_quiz-attempt-details.scss | 85 ------------------- 2 files changed, 2 insertions(+), 89 deletions(-) diff --git a/assets/src/js/v3/shared/utils/quiz.ts b/assets/src/js/v3/shared/utils/quiz.ts index d886b6fdf1..b3cd0057ec 100644 --- a/assets/src/js/v3/shared/utils/quiz.ts +++ b/assets/src/js/v3/shared/utils/quiz.ts @@ -115,11 +115,9 @@ export const convertedQuestion = (question: Omit): 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; + const rawThreshold = question.question_settings.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_settings.draw_image_threshold_percent = Number(rawThreshold); } } } diff --git a/assets/src/scss/frontend/components/_quiz-attempt-details.scss b/assets/src/scss/frontend/components/_quiz-attempt-details.scss index 769c050d95..ca469b102c 100644 --- a/assets/src/scss/frontend/components/_quiz-attempt-details.scss +++ b/assets/src/scss/frontend/components/_quiz-attempt-details.scss @@ -358,88 +358,3 @@ margin-bottom: $tutor-spacing-6; } } - -// Draw on Image — attempt review: correct (green) + student (red) masks on one background. -.tutor-quiz-draw-image-review { - .tutor-draw-image-review-legend { - @include tutor-flex(row); - flex-wrap: wrap; - gap: $tutor-spacing-6; - margin: 0; - padding: 0; - list-style: none; - } - - .tutor-draw-image-review-legend__item { - @include tutor-flex(row, center); - gap: $tutor-spacing-3; - } - - .tutor-draw-image-review-swatch { - flex-shrink: 0; - width: 12px; - height: 12px; - border-radius: 50%; - - &--correct { - background-color: rgba(4, 201, 134, 0.35); - box-shadow: 0 0 0 1px #53b96a; - } - - &--student { - background-color: rgba(233, 62, 62, 0.35); - box-shadow: 0 0 0 1px #e53935; - } - } - - .tutor-draw-image-review-inner { - position: relative; - display: inline-block; - overflow: hidden; - max-width: 100%; - } - - .tutor-draw-image-review-inner .tutor-draw-image-bg { - display: block; - max-width: 100%; - height: auto; - } - - .tutor-draw-image-review-mask { - position: absolute; - inset: 0; - display: block; - width: 100%; - height: 100%; - pointer-events: none; - background: var(--tutor-draw-mask-bg); - -webkit-mask-image: var(--tutor-draw-mask-url); - -webkit-mask-repeat: no-repeat; - -webkit-mask-size: 100% 100%; - mask-image: var(--tutor-draw-mask-url); - mask-repeat: no-repeat; - mask-size: 100% 100%; - } - - .tutor-draw-image-review-mask--correct { - filter: - drop-shadow(1px 0 0 #53b96a) - drop-shadow(-1px 0 0 #53b96a) - drop-shadow(0 1px 0 #53b96a) - drop-shadow(0 -1px 0 #53b96a); - } - - .tutor-draw-image-review-mask--student { - filter: - drop-shadow(1px 0 0 #F80000) - drop-shadow(-1px 0 0 #F80000) - drop-shadow(0 1px 0 #F80000) - drop-shadow(0 -1px 0 #F80000); - } - - .tutor-draw-image-review-fallback .tutor-draw-image-single { - display: block; - max-width: 100%; - height: auto; - } -} From 4247b55b642aedb9ea120da4217efaa0ab6fb108 Mon Sep 17 00:00:00 2001 From: Sadman Soumique Date: Fri, 3 Apr 2026 11:27:43 +0600 Subject: [PATCH 14/14] Refactor is_legacy_learning_mode method in Utils class - Changed the `is_legacy_learning_mode` method from static to instance method to utilize the instance context for fetching options. - This change improves the flexibility and maintainability of the method within the Utils class. --- classes/Utils.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/classes/Utils.php b/classes/Utils.php index cc708a2c1c..32c28c3c5e 100644 --- a/classes/Utils.php +++ b/classes/Utils.php @@ -11143,7 +11143,7 @@ public static function is_kids_mode(): bool { * * @return bool */ - public static function is_legacy_learning_mode(): bool { - return Options_V2::LEARNING_MODE_LEGACY === tutor_utils()->get_option( 'learning_mode' ); + public function is_legacy_learning_mode(): bool { + return Options_V2::LEARNING_MODE_LEGACY === $this->get_option( 'learning_mode' ); } }