diff --git a/assets/core/ts/declaration.d.ts b/assets/core/ts/declaration.d.ts index 57fab0c735..a7dbde4902 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; @@ -108,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/front/course/_spotlight-quiz.js b/assets/src/js/front/course/_spotlight-quiz.js index 2c3724d65f..bb76c2c3d4 100644 --- a/assets/src/js/front/course/_spotlight-quiz.js +++ b/assets/src/js/front/course/_spotlight-quiz.js @@ -2,7 +2,7 @@ window.jQuery(document).ready($ => { const { __ } = window.wp.i18n; // Currently only these types of question supports answer reveal mode. - const revealModeSupportedQuestions = ['true_false', 'single_choice', 'multiple_choice', 'draw_image']; + const revealModeSupportedQuestions = ['true_false', 'single_choice', 'multiple_choice', 'draw_image', 'pin_image']; let quiz_options = _tutorobject.quiz_options let interactions = new Map(); @@ -102,10 +102,11 @@ window.jQuery(document).ready($ => { }); } - // Reveal mode for draw_image: show reference (instructor mask) and explanation. - if (is_reveal_mode() && $question_wrap.data('question-type') === 'draw_image') { + // Reveal mode for draw_image & pin_image: show reference (instructor mask) and explanation. + if (is_reveal_mode() && ['draw_image', 'pin_image'].includes($question_wrap.data('question-type'))) { $question_wrap.find('.tutor-quiz-explanation-wrapper').removeClass('tutor-d-none'); $question_wrap.find('.tutor-draw-image-reference-wrapper').removeClass('tutor-d-none'); + $question_wrap.find('.tutor-pin-image-reference-wrapper').removeClass('tutor-d-none'); goNext = true; } @@ -174,6 +175,17 @@ window.jQuery(document).ready($ => { $question_wrap.find('.answer-help-block').html(`

${__('Please draw on the image to answer this question.', 'tutor')}

`); validated = false; } + } else if ($question_wrap.data('question-type') === 'pin_image') { + // Pin image: require normalized pin coordinates (hidden inputs [answers][pin][x|y]). + var $pinX = $required_answer_wrap.find('input[name*="[answers][pin][x]"]'); + var $pinY = $required_answer_wrap.find('input[name*="[answers][pin][y]"]'); + if ( + !$pinX.length || !$pinY.length || + !$pinX.val().trim().length || !$pinY.val().trim().length + ) { + $question_wrap.find('.answer-help-block').html(`

${__('Please drop a pin on the image to answer this question.', 'tutor')}

`); + validated = false; + } } else if ($type === 'radio') { if ($required_answer_wrap.find('input[type="radio"]:checked').length == 0) { $question_wrap.find('.answer-help-block').html(`

${__('Please select an option to answer', 'tutor')}

`); diff --git a/assets/src/js/v3/@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/Question.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx index b2846d5eae..44f62af47f 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/Question.tsx @@ -40,6 +40,7 @@ const questionTypeIconMap: Record { image_answering: , ordering: , draw_image: , + pin_image: , } as const; useEffect(() => { diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx index 42a4d80289..3f5194fc74 100644 --- a/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/QuestionList.tsx @@ -103,18 +103,31 @@ 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', + // TODO: icon is not final. + icon: 'quizImageAnswer', + isPro: true, + }, + { + label: __('Pin on Image', 'tutor'), + value: 'pin_image', + // TODO: icon is not final. + icon: 'quizImageAnswer', + isPro: true, + }, ]; 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' && option.value !== 'pin_image'); + } + return questionTypeOptions; + }, []); const [activeSortId, setActiveSortId] = useState(null); const [isOpen, setIsOpen] = useState(false); const questionListRef = useRef(null); @@ -230,7 +243,22 @@ const QuestionList = ({ isEditing }: { isEditing: boolean }) => { is_correct: '1', }, ] - : [], + : questionType === 'pin_image' + ? [ + { + _data_status: QuizDataStatus.NEW, + is_saved: true, + answer_id: nanoid(), + answer_title: '', + belongs_question_id: questionId, + belongs_question_type: 'pin_image', + answer_two_gap_match: '', + answer_view_format: 'pin_image', + answer_order: 0, + is_correct: '1', + }, + ] + : [], answer_explanation: '', question_mark: 1, question_order: questionFields.length + 1, @@ -446,7 +474,7 @@ const QuestionList = ({ isEditing }: { isEditing: boolean }) => { >
{__('Select Question Type', 'tutor')} - {questionTypeOptions.map((option) => ( + {questionTypeOptionsForUi.map((option) => ( { 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; @@ -54,15 +78,41 @@ const DrawImage = () => { return (
( - ( + ( + { + thresholdControllerProps.field.onChange(option.value); + if (calculateQuizDataStatus(activeQuestionDataStatus, QuizDataStatus.UPDATE)) { + form.setValue( + `questions.${activeQuestionIndex}._data_status`, + calculateQuizDataStatus(activeQuestionDataStatus, QuizDataStatus.UPDATE) as QuizDataStatus, + ); + } + }} + /> + } + /> + )} /> )} /> @@ -75,6 +125,7 @@ export default DrawImage; const styles = { optionWrapper: css` ${styleUtils.display.flex('column')}; + gap: ${spacing[16]}; padding-left: ${spacing[40]}; `, }; diff --git a/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/PinImage.tsx b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/PinImage.tsx new file mode 100644 index 0000000000..2bc91ba5f4 --- /dev/null +++ b/assets/src/js/v3/entries/course-builder/components/curriculum/question-types/PinImage.tsx @@ -0,0 +1,80 @@ +import { css } from '@emotion/react'; +import { useEffect } from 'react'; +import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; + +import { useQuizModalContext } from '@CourseBuilderContexts/QuizModalContext'; +import type { QuizForm } from '@CourseBuilderServices/quiz'; +import FormPinImage from '@TutorShared/components/fields/quiz/questions/FormPinImage'; +import { spacing } from '@TutorShared/config/styles'; +import { styleUtils } from '@TutorShared/utils/style-utils'; +import { QuizDataStatus, type QuizQuestionOption } from '@TutorShared/utils/types'; +import { nanoid } from '@TutorShared/utils/util'; + +const PinImage = () => { + const form = useFormContext(); + const { activeQuestionId, activeQuestionIndex, validationError, setValidationError } = useQuizModalContext(); + + const answersPath = `questions.${activeQuestionIndex}.question_answers` as 'questions.0.question_answers'; + + const { fields: optionsFields } = useFieldArray({ + control: form.control, + name: answersPath, + }); + + // Ensure there is always a single option for this question type. + useEffect(() => { + if (!activeQuestionId) { + return; + } + if (optionsFields.length > 0) { + return; + } + const baseAnswer: QuizQuestionOption = { + _data_status: QuizDataStatus.NEW, + is_saved: false, + answer_id: nanoid(), + belongs_question_id: activeQuestionId, + belongs_question_type: 'pin_image' as QuizQuestionOption['belongs_question_type'], + answer_title: '', + is_correct: '1', + image_id: undefined, + image_url: '', + answer_two_gap_match: '', + answer_view_format: 'pin_image', + answer_order: 0, + }; + form.setValue(answersPath, [baseAnswer]); + }, [activeQuestionId, optionsFields.length, answersPath, form]); + + // Only render Controller when the value exists to ensure field.value is always defined + if (optionsFields.length === 0) { + return null; + } + + return ( +
+ ( + + )} + /> +
+ ); +}; + +export default PinImage; + +const styles = { + optionWrapper: css` + ${styleUtils.display.flex('column')}; + padding-left: ${spacing[40]}; + `, +}; diff --git a/assets/src/js/v3/entries/course-builder/services/quiz.ts b/assets/src/js/v3/entries/course-builder/services/quiz.ts index 3457fa8b03..cce5c09891 100644 --- a/assets/src/js/v3/entries/course-builder/services/quiz.ts +++ b/assets/src/js/v3/entries/course-builder/services/quiz.ts @@ -43,6 +43,7 @@ interface QuizQuestionsForPayload extends Omit 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..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 @@ -2,7 +2,6 @@ import { css } from '@emotion/react'; import { __ } from '@wordpress/i18n'; import { useCallback, useEffect, useRef, useState } from 'react'; -import Button from '@TutorShared/atoms/Button'; import ImageInput from '@TutorShared/atoms/ImageInput'; import SVGIcon from '@TutorShared/atoms/SVGIcon'; @@ -20,7 +19,10 @@ import { type QuizValidationErrorType, } from '@TutorShared/utils/types'; -const INSTRUCTOR_STROKE_STYLE = 'rgba(255, 0, 0, 0.9)'; +const LASSO_FILL_STYLE = 'rgba(220, 53, 69, 0.45)'; +const LASSO_STROKE_STYLE = 'rgba(220, 53, 69, 0.95)'; +const LASSO_DASH_PATTERN = [8, 6]; +const LASSO_MIN_POINT_DISTANCE = 4; interface FormDrawImageProps extends FormControllerProps { questionId: ID; @@ -34,9 +36,10 @@ 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); @@ -44,6 +47,9 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { 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) => { @@ -109,7 +115,6 @@ const FormDrawImage = ({ field }: 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) && { @@ -120,7 +125,6 @@ const FormDrawImage = ({ field }: 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; @@ -137,15 +141,6 @@ 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; @@ -192,37 +187,154 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { }; }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match, syncCanvasDisplay]); - // Wire to shared draw-on-image module when draw mode is active (Tutor Pro). + // Draw-image instructor UI: same lasso polygon flow as FormPinImage (feat/quiz-type-pin-image). useEffect(() => { if (!isDrawModeActive || !option?.image_url) { return; } - const img = imageRef.current; const canvas = canvasRef.current; - const api = typeof window !== 'undefined' ? window.TutorCore?.drawOnImage : undefined; - if (!img || !canvas || !api?.init) { + if (!canvas) { return; } + if (drawInstanceRef.current) { drawInstanceRef.current.destroy(); drawInstanceRef.current = null; } - const brushSize = api.DEFAULT_BRUSH_SIZE ?? 15; - const instance = api.init({ - image: img, - canvas, - brushSize, - strokeStyle: INSTRUCTOR_STROKE_STYLE, - initialMaskUrl: option.answer_two_gap_match || undefined, - }); + + const getPointFromEvent = (event: PointerEvent) => { + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + const x = (event.clientX - rect.left) * scaleX; + const y = (event.clientY - rect.top) * scaleY; + return { + x: Math.max(0, Math.min(canvas.width, x)), + y: Math.max(0, Math.min(canvas.height, y)), + }; + }; + + const renderPathPreview = () => { + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + const points = lassoPointsRef.current; + if (!baseImageDataRef.current || points.length < 2) { + return; + } + + ctx.putImageData(baseImageDataRef.current, 0, 0); + ctx.beginPath(); + ctx.moveTo(points[0]?.x || 0, points[0]?.y || 0); + points.forEach((point, index) => { + if (index > 0) { + ctx.lineTo(point.x, point.y); + } + }); + ctx.lineTo(points[0]?.x || 0, points[0]?.y || 0); + ctx.closePath(); + + ctx.fillStyle = LASSO_FILL_STYLE; + ctx.fill(); + + ctx.setLineDash(LASSO_DASH_PATTERN); + ctx.lineWidth = 2; + ctx.strokeStyle = LASSO_STROKE_STYLE; + ctx.stroke(); + ctx.setLineDash([]); + }; + + const onPointerDown = (event: PointerEvent) => { + if (event.button !== 0) { + return; + } + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + canvas.setPointerCapture(event.pointerId); + isLassoDrawingRef.current = true; + lassoPointsRef.current = [getPointFromEvent(event)]; + baseImageDataRef.current = ctx.getImageData(0, 0, canvas.width, canvas.height); + }; + + const onPointerMove = (event: PointerEvent) => { + if (!isLassoDrawingRef.current) { + return; + } + const nextPoint = getPointFromEvent(event); + const points = lassoPointsRef.current; + const lastPoint = points[points.length - 1]; + if (!lastPoint) { + points.push(nextPoint); + renderPathPreview(); + return; + } + const dx = nextPoint.x - lastPoint.x; + const dy = nextPoint.y - lastPoint.y; + if (Math.hypot(dx, dy) < LASSO_MIN_POINT_DISTANCE) { + return; + } + points.push(nextPoint); + renderPathPreview(); + }; + + const finishLasso = () => { + if (!isLassoDrawingRef.current) { + return; + } + isLassoDrawingRef.current = false; + const points = lassoPointsRef.current; + if (points.length >= 3) { + renderPathPreview(); + } else if (baseImageDataRef.current) { + const ctx = canvas.getContext('2d'); + ctx?.putImageData(baseImageDataRef.current, 0, 0); + } + lassoPointsRef.current = []; + baseImageDataRef.current = null; + }; + + const onPointerUp = (event: PointerEvent) => { + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + finishLasso(); + }; + + const onPointerCancel = (event: PointerEvent) => { + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + finishLasso(); + }; + + canvas.addEventListener('pointerdown', onPointerDown); + canvas.addEventListener('pointermove', onPointerMove); + canvas.addEventListener('pointerup', onPointerUp); + canvas.addEventListener('pointercancel', onPointerCancel); + + const instance = { + destroy: () => { + canvas.removeEventListener('pointerdown', onPointerDown); + canvas.removeEventListener('pointermove', onPointerMove); + canvas.removeEventListener('pointerup', onPointerUp); + canvas.removeEventListener('pointercancel', onPointerCancel); + isLassoDrawingRef.current = false; + lassoPointsRef.current = []; + baseImageDataRef.current = null; + }, + }; drawInstanceRef.current = instance; + return () => { instance.destroy(); drawInstanceRef.current = null; }; }, [isDrawModeActive, option?.image_url, option?.answer_two_gap_match]); - // Cleanup shared instance on unmount. useEffect(() => { return () => { if (drawInstanceRef.current) { @@ -232,13 +344,9 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { }; }, []); - if (!option) { - return null; - } - - const handleSave = () => { + const persistCanvasMask = useCallback(() => { const canvas = canvasRef.current; - if (!canvas) { + if (!canvas || !option) { return; } @@ -257,15 +365,13 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { is_saved: true, }; updateOption(updated); + }, [option, updateOption]); - if (drawInstanceRef.current) { - drawInstanceRef.current.destroy(); - drawInstanceRef.current = null; + const handleClear = () => { + if (!option) { + return; } - setIsDrawModeActive(false); - }; - const handleClear = () => { if (drawInstanceRef.current) { drawInstanceRef.current.destroy(); drawInstanceRef.current = null; @@ -286,14 +392,23 @@ const FormDrawImage = ({ field }: FormDrawImageProps) => { is_saved: true, }; updateOption(updated); - setIsDrawModeActive(false); }; - const handleDraw = () => { + const handleCanvasMouseEnter = () => { setIsDrawModeActive(true); }; + const handleCanvasMouseLeave = () => { + if (!isLassoDrawingRef.current) { + setIsDrawModeActive(false); + } + }; + const clearImage = () => { + if (!option) { + return; + } + if (drawInstanceRef.current) { drawInstanceRef.current.destroy(); drawInstanceRef.current = null; @@ -319,32 +434,79 @@ const FormDrawImage = ({ field }: 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 */} -
-
- + +
+
+ +
-
+ + + +
+
+ {__('Background { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + openMediaLibrary(); + } + }} + /> +
+
+
- {/* Section 2: Mark the correct area — single reference image + drawing canvas; Save / Clear / Draw buttons */}
@@ -354,8 +516,12 @@ 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__)} @@ -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__)} + +
+ +
+
+
+ {__('Background + +
+ +

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

+
+
+
+ + +

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

+
+
+ ); +}; + +export default FormPinImage; + +const styles = { + wrapper: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[24]}; + padding-left: ${spacing[40]}; + + ${Breakpoint.smallMobile} { + padding-left: ${spacing[8]}; + } + `, + card: css` + ${styleUtils.display.flex('column')}; + gap: ${spacing[16]}; + padding: ${spacing[20]}; + background: ${colorTokens.surface.tutor}; + border-radius: ${borderRadius.card}; + `, + imageInputWrapper: css` + max-width: 100%; + `, + imageInputEmpty: css` + border-radius: ${borderRadius.card}; + `, + imageInputPreview: css` + width: fit-content; + max-width: 100%; + height: auto; + border-radius: ${borderRadius.card}; + + img { + width: auto; + max-width: 100%; + height: auto; + object-fit: initial; + } + `, + answerHeader: css` + ${styleUtils.display.flex('row')}; + align-items: center; + justify-content: space-between; + gap: ${spacing[12]}; + `, + answerHeaderTitle: css` + ${typography.body('medium')}; + color: ${colorTokens.text.primary}; + ${styleUtils.display.flex('row')}; + align-items: center; + gap: ${spacing[8]}; + `, + headerIcon: css` + flex-shrink: 0; + color: ${colorTokens.text.subdued}; + `, + canvasInner: css` + position: relative; + display: inline-block; + border-radius: ${borderRadius.card}; + overflow: hidden; + + img { + display: block; + max-width: 100%; + height: auto; + } + `, + image: css` + display: block; + max-width: 100%; + height: auto; + `, + answerImage: css` + filter: grayscale(0.1); + `, + canvas: css` + position: absolute; + top: 0; + left: 0; + z-index: 1; + `, + canvasIdleMode: css` + pointer-events: none; + cursor: default; + `, + canvasDrawMode: css` + pointer-events: auto; + cursor: crosshair; + `, + actionsRow: css` + ${styleUtils.display.flex('row')}; + gap: ${spacing[12]}; + flex-wrap: wrap; + color: ${colorTokens.text.brand}; + `, + clearButton: css` + width: 94px; + border: none; + border-radius: ${borderRadius.input}; + background: ${colorTokens.action.secondary.default}; + ${typography.caption('medium')}; + color: ${colorTokens.text.brand}; + display: flex; + justify-content: center; + align-items: center; + gap: ${spacing[8]}; + padding: ${spacing[4]} 0; + cursor: pointer; + `, + clearButtonIcon: css` + color: ${colorTokens.text.brand}; + `, + brushHint: css` + ${typography.caption()}; + color: ${colorTokens.text.subdued}; + margin: 0; + `, + savedHint: css` + ${typography.caption()}; + color: ${colorTokens.text.success}; + margin: 0; + `, + placeholder: css` + ${typography.caption()}; + color: ${colorTokens.text.subdued}; + `, +}; diff --git a/assets/src/js/v3/shared/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' ); +?> + +
+ +
+ <?php esc_attr_e( 'Draw on image question', 'tutor' ); ?> + +
+ + + + +

+ +

+ + + + +

+ +

+
+ +
+ 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})"; +?> + +
+ +
+ <?php esc_attr_e( 'Pin 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..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 ); + ?> +
+ + +
+

+ +

+ +
+ + +
+ + + +
+ +
+ + +
+ + +

+ +

+ +
+ + + + + + + +
+ + +
+
+ + +
+
+ +

+ +

+ + + +
+
+ + +
+
+ + +

+ +

+ +
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. */