@tinyweb_dev/oe-exam-sdk 1.0.0 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/exam-taking.types.d.ts +1 -0
- package/dist/components/exams/ExamPreviewDialog.js +4 -2
- package/dist/components/exams/take/components/QuestionRenderer.js +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +3 -1
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +5 -2
- package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +13 -20
- package/dist/components/exams/take/exam-taking.utils.d.ts +3 -2
- package/dist/components/exams/take/exam-taking.utils.js +3 -0
- package/dist/components/exams/take/utils/question-transformers.d.ts +11 -3
- package/dist/components/exams/take/utils/question-transformers.js +45 -2
- package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.d.ts +6 -2
- package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.js +26 -5
- package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +9 -0
- package/dist/components/questions/_shared/types/question-group.type.d.ts +11 -0
- package/dist/components/questions/_shared/types/question-group.type.js +4 -1
- package/dist/components/questions/_shared/types/spontaneous-qa.type.d.ts +7 -0
- package/dist/components/questions/_shared/types/word-fill-structured-form.type.d.ts +1 -1
- package/dist/components/questions/groups/ImageLabelGroupCard.d.ts +15 -0
- package/dist/components/questions/groups/ImageLabelGroupCard.js +42 -0
- package/dist/components/questions/groups/ImageLabelWordBank.d.ts +10 -0
- package/dist/components/questions/groups/ImageLabelWordBank.js +17 -0
- package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +33 -9
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +6 -2
- package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +28 -5
- package/dist/components/questions/types/fill-in-blank/register.js +5 -0
- package/dist/components/questions/types/fill-in-blank/transform.js +4 -1
- package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +11 -0
- package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +27 -0
- package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.d.ts +2 -0
- package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.js +15 -0
- package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.d.ts +2 -0
- package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.js +33 -0
- package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.d.ts +2 -2
- package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.js +15 -5
- package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +39 -15
- package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +35 -12
- package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +2 -10
- package/dist/components/questions/types/word-fill-structured-form/transform.js +2 -1
- package/dist/components/questions/types/word-ordering/WordOrderingClient.js +51 -33
- package/dist/components/questions/viewer/QuestionViewer.js +12 -68
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +3 -1
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +4 -2
- package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
- package/dist/components/themes/cambridge-yle/CambridgeYleInstructionBanner.js +12 -3
- package/dist/components/themes/english-certification/EcWordFillStructuredForm.js +13 -20
- package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js +8 -0
- package/dist/shared/constants/routes.d.ts +2 -2
- package/dist/shared/constants/routes.js +3 -3
- package/dist/shared/lib/rich-text.d.ts +4 -0
- package/dist/shared/lib/rich-text.js +25 -0
- package/dist/shared/lib/stores/examQuestionStore.d.ts +4 -0
- package/dist/shared/lib/utils/question-transform-types.d.ts +4 -0
- package/dist/shared/lib/utils/question-transform.js +1 -1
- package/dist/shared/lib/utils/wfsf-blank-match.d.ts +9 -0
- package/dist/shared/lib/utils/wfsf-blank-match.js +60 -0
- package/package.json +1 -1
- package/dist/components/themes/cambridge-yle/assets/images/bg-title-old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/border-old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/close_old.png +0 -0
- package/dist/components/themes/cambridge-yle/assets/images/frame-old.png +0 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FillBlankQuestion } from '../../../components/exams/take/types';
|
|
2
|
+
import { type ImageLabelData } from '../../questions/_shared/types/question-group.type';
|
|
2
3
|
interface ReviewListenAndWriteRendererProps {
|
|
3
4
|
partNumber: number;
|
|
4
5
|
questionCount: number;
|
|
@@ -12,6 +13,7 @@ interface ReviewListenAndWriteRendererProps {
|
|
|
12
13
|
questions: FillBlankQuestion[];
|
|
13
14
|
answers: Record<string, unknown>;
|
|
14
15
|
isCorrectMap: Record<string, boolean | null>;
|
|
16
|
+
imageLabels?: ImageLabelData[];
|
|
15
17
|
}
|
|
16
|
-
export declare function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, }: ReviewListenAndWriteRendererProps): import("react").JSX.Element;
|
|
18
|
+
export declare function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, imageLabels, }: ReviewListenAndWriteRendererProps): import("react").JSX.Element;
|
|
17
19
|
export {};
|
|
@@ -6,7 +6,9 @@ import CambridgeYlePartBanner from '../../../components/themes/cambridge-yle/Cam
|
|
|
6
6
|
import CambridgeYleInstructionBanner from '../../../components/themes/cambridge-yle/CambridgeYleInstructionBanner';
|
|
7
7
|
import CambridgeYleAudioPlayer from '../../../components/themes/cambridge-yle/CambridgeYleAudioPlayer';
|
|
8
8
|
import { countBlanks, formatAcceptedAnswers, hasAnyBlankValue, isBlankCorrect, toBlankValues, } from '../../../shared/lib/utils/fill-in-blank';
|
|
9
|
-
|
|
9
|
+
import { ImageLabelWordBank } from '../../questions/groups/ImageLabelWordBank';
|
|
10
|
+
import { hasImageLabels } from '../../questions/_shared/types/question-group.type';
|
|
11
|
+
export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, imageLabels, }) {
|
|
10
12
|
/**
|
|
11
13
|
* Get answer status from API's isCorrect field
|
|
12
14
|
* Returns: 'correct' | 'incorrect' | 'unanswered'
|
|
@@ -44,7 +46,7 @@ export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instru
|
|
|
44
46
|
return 'unanswered';
|
|
45
47
|
return isBlankCorrect(userValue, accepted) ? 'correct' : 'incorrect';
|
|
46
48
|
};
|
|
47
|
-
return (_jsxs("div", { className: "flex flex-col gap-6 h-full", children: [_jsxs("div", { className: "flex flex-col gap-4 pb-0 pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: true }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: true }), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true })] }), _jsxs("div", { className: "flex flex-col gap-6 pb-6 pl-2 pt-2", children: [_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title }), _jsx("div", { className: "flex flex-col gap-4", children: questions.map((question) => {
|
|
49
|
+
return (_jsxs("div", { className: "flex flex-col gap-6 h-full", children: [_jsxs("div", { className: "flex flex-col gap-4 pb-0 pt-4", children: [_jsx(CambridgeYlePartBanner, { partNumber: partNumber, questionCount: questionCount, partName: partName, isReviewMode: true }), _jsx(CambridgeYleInstructionBanner, { instruction: instruction, hasExample: !!exampleText, isReviewMode: true }), hasImageLabels(imageLabels) && (_jsx(ImageLabelWordBank, { items: imageLabels, className: "rounded-2xl border border-slate-200 bg-slate-50/80 p-4 shadow-sm" })), audioUrl && _jsx(CambridgeYleAudioPlayer, { audioSrc: audioUrl, compact: true })] }), _jsxs("div", { className: "flex flex-col gap-6 pb-6 pl-2 pt-2", children: [_jsx("h2", { className: "text-center text-3xl font-bold uppercase text-coral-500", children: title }), _jsx("div", { className: "flex flex-col gap-4", children: questions.map((question) => {
|
|
48
50
|
const status = getAnswerStatus(question.id);
|
|
49
51
|
const userValues = toBlankValues(answers[question.id], countBlanks(question.segments));
|
|
50
52
|
const renderBlank = (blankIndex) => {
|
|
@@ -27,8 +27,8 @@ export function renderDedicatedReviewBody(ctx) {
|
|
|
27
27
|
const apiQuestions = asApiQuestions(questions);
|
|
28
28
|
switch (questionType) {
|
|
29
29
|
case 'FILL_IN_BLANK': {
|
|
30
|
-
const data = transformFillInBlank(apiQuestions);
|
|
31
|
-
return (_jsx(ReviewListenAndWriteRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.imageUrl, audioUrl: data.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap }));
|
|
30
|
+
const data = transformFillInBlank(apiQuestions, { suggestedAnswers: part.suggestedAnswers });
|
|
31
|
+
return (_jsx(ReviewListenAndWriteRenderer, { partNumber: partNo, questionCount: questionCount, partName: part.name, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.imageUrl, audioUrl: data.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: data.questions, answers: answers, isCorrectMap: isCorrectMap, imageLabels: data.imageLabels }));
|
|
32
32
|
}
|
|
33
33
|
case 'WRITE_CORRECT_VERB_FORM': {
|
|
34
34
|
const data = transformWriteCorrectVerbForm(apiQuestions);
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Info, Megaphone } from 'lucide-react';
|
|
4
|
+
/** CMS instruction may be plain text or HTML (e.g. `<p>…</p>`). */
|
|
5
|
+
function isHtmlContent(value) {
|
|
6
|
+
return /<[a-z][\s\S]*>/i.test(value);
|
|
7
|
+
}
|
|
8
|
+
function InstructionHtml({ html, className }) {
|
|
9
|
+
return (_jsx("div", { className: className,
|
|
10
|
+
// Exam CMS content — not end-user input.
|
|
11
|
+
dangerouslySetInnerHTML: { __html: html } }));
|
|
12
|
+
}
|
|
4
13
|
export default function CambridgeYleInstructionBanner({ instruction, instructions, hasExample = false, children, isReviewMode = false, compact = false, }) {
|
|
5
14
|
if (isReviewMode)
|
|
6
15
|
return null;
|
|
@@ -11,9 +20,9 @@ export default function CambridgeYleInstructionBanner({ instruction, instruction
|
|
|
11
20
|
? 'flex w-full items-center gap-2 rounded-[14px] border-2 border-dashed border-blue-300 bg-[#F8FBFF] px-3 py-2 transition-all hover:bg-blue-50/50 lg:gap-3 lg:px-4 lg:py-2.5 xl:gap-5 xl:rounded-[24px] xl:px-5 xl:py-4'
|
|
12
21
|
: 'flex w-full items-center gap-4 rounded-[24px] border-2 border-dashed border-blue-300 bg-[#F8FBFF] px-5 py-4 transition-all hover:bg-blue-50/50 sm:gap-5', children: [_jsx("div", { className: compact
|
|
13
22
|
? 'flex h-8 w-8 shrink-0 -rotate-6 items-center justify-center rounded-[10px] bg-[#0070FF] shadow-[3px_3px_0px_rgba(191,219,254,1)] transition-transform hover:rotate-0 lg:h-9 lg:w-9 xl:h-14 xl:w-14 xl:rounded-[16px] xl:shadow-[4px_4px_0px_rgba(191,219,254,1)]'
|
|
14
|
-
: 'flex h-12 w-12 shrink-0 -rotate-6 items-center justify-center rounded-[16px] bg-[#0070FF] shadow-[4px_4px_0px_rgba(191,219,254,1)] transition-transform hover:rotate-0 sm:h-14 sm:w-14', children: _jsx(Megaphone, { className: compact ? 'h-4 w-4 text-white lg:h-5 lg:w-5 xl:h-7 xl:w-7' : 'h-6 w-6 text-white sm:h-7 sm:w-7', strokeWidth: 2.5 }) }), _jsx("div", { className: "flex flex-1 flex-col justify-center", children: _jsxs("
|
|
23
|
+
: 'flex h-12 w-12 shrink-0 -rotate-6 items-center justify-center rounded-[16px] bg-[#0070FF] shadow-[4px_4px_0px_rgba(191,219,254,1)] transition-transform hover:rotate-0 sm:h-14 sm:w-14', children: _jsx(Megaphone, { className: compact ? 'h-4 w-4 text-white lg:h-5 lg:w-5 xl:h-7 xl:w-7' : 'h-6 w-6 text-white sm:h-7 sm:w-7', strokeWidth: 2.5 }) }), _jsx("div", { className: "flex flex-1 flex-col justify-center", children: _jsxs("div", { className: compact
|
|
15
24
|
? 'text-[12px] font-black uppercase leading-tight tracking-wide text-[#002868] lg:text-[13px] xl:text-[18px] xl:leading-snug'
|
|
16
|
-
: 'text-[15px] font-black uppercase leading-snug tracking-wide text-[#002868] sm:text-[18px]', style: { fontFamily: '"SVN-Volte Rounded", "Quicksand", sans-serif' }, children: [instruction, hasExample && (_jsx("span", { className: compact
|
|
25
|
+
: 'text-[15px] font-black uppercase leading-snug tracking-wide text-[#002868] sm:text-[18px]', style: { fontFamily: '"SVN-Volte Rounded", "Quicksand", sans-serif' }, children: [isHtmlContent(instruction) ? (_jsx(InstructionHtml, { html: instruction, className: "contents [&_p]:m-0 [&_p]:inline" })) : (instruction), hasExample && (_jsx("span", { className: compact
|
|
17
26
|
? 'ml-1 inline-block rounded-full bg-blue-100 px-1.5 py-0.5 align-middle text-[10px] font-bold tracking-normal text-[#0070FF] xl:-mt-1 xl:ml-2 xl:px-3 xl:text-[16px]'
|
|
18
|
-
: 'ml-2 -mt-1 inline-block rounded-full bg-blue-100 px-3 py-0.5 align-middle text-[14px] font-bold tracking-normal text-[#0070FF] sm:text-[16px]', children: "THERE IS ONE EXAMPLE" }))] }) })] })), children, hasInstructionsList && (_jsx("div", { className: "rounded-lg border border-blue-200 bg-white p-4 shadow-sm", children: _jsxs("div", { className: "space-y-3", children: [_jsxs("h4", { className: "flex items-center gap-2 font-semibold text-blue-900", children: [_jsx(Info, { className: "h-4 w-4" }), "Instructions"] }), _jsx("ol", { className: "list-inside list-decimal space-y-1.5 text-sm text-gray-700", children: instructions.map((item, index) => (_jsx("li", { className: "leading-relaxed", children: item }, index))) })] }) }))] }));
|
|
27
|
+
: 'ml-2 -mt-1 inline-block rounded-full bg-blue-100 px-3 py-0.5 align-middle text-[14px] font-bold tracking-normal text-[#0070FF] sm:text-[16px]', children: "THERE IS ONE EXAMPLE" }))] }) })] })), children, hasInstructionsList && (_jsx("div", { className: "rounded-lg border border-blue-200 bg-white p-4 shadow-sm", children: _jsxs("div", { className: "space-y-3", children: [_jsxs("h4", { className: "flex items-center gap-2 font-semibold text-blue-900", children: [_jsx(Info, { className: "h-4 w-4" }), "Instructions"] }), _jsx("ol", { className: "list-inside list-decimal space-y-1.5 text-sm text-gray-700", children: instructions.map((item, index) => (_jsx("li", { className: "leading-relaxed", children: isHtmlContent(item) ? (_jsx(InstructionHtml, { html: item, className: "[&_p]:m-0" })) : (item) }, index))) })] }) }))] }));
|
|
19
28
|
}
|
|
@@ -5,6 +5,7 @@ import { ResolvedImage } from '../../../components/common/ResolvedImage';
|
|
|
5
5
|
import { cn } from '../../../shared/lib/utils';
|
|
6
6
|
import { transformWordFillStructuredForm } from '../../../components/exams/take/exam-taking.utils';
|
|
7
7
|
import { englishCertQuestionDomId } from './english-cert-scroll';
|
|
8
|
+
import { expandWfsfCorrectAlts, formatWfsfCorrectLabel, isWfsfBlankCorrect, } from '../../../shared/lib/utils/wfsf-blank-match';
|
|
8
9
|
/**
|
|
9
10
|
* Replace `{key}` placeholders in raw HTML with inline `<input>` elements so the
|
|
10
11
|
* full HTML structure (tables, forms, …) is preserved and inputs render inside
|
|
@@ -25,14 +26,6 @@ function buildHtmlWithInputPlaceholders(html) {
|
|
|
25
26
|
`;
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
|
-
function normalizeStructuredAnswer(value, caseSensitive) {
|
|
29
|
-
const normalized = value
|
|
30
|
-
.trim()
|
|
31
|
-
.replace(/[\p{P}]/gu, '')
|
|
32
|
-
.replace(/\s+/g, ' ')
|
|
33
|
-
.trim();
|
|
34
|
-
return caseSensitive ? normalized : normalized.toLowerCase();
|
|
35
|
-
}
|
|
36
29
|
/**
|
|
37
30
|
* Build the card label from the blank keys (which are the IELTS question numbers).
|
|
38
31
|
* Returns "Questions x–y" for a numeric range, a single "Question n", or falls
|
|
@@ -82,28 +75,28 @@ const StructuredFormBody = memo(function StructuredFormBody({ question, initialA
|
|
|
82
75
|
const key = input.getAttribute('data-blank-key') || '';
|
|
83
76
|
const wrapper = container.querySelector(`[data-blank-wrapper="${key}"]`);
|
|
84
77
|
const userAnswer = initialAnswersRef.current[key] || '';
|
|
85
|
-
const correctAnswer = question.answers[key]
|
|
78
|
+
const correctAnswer = question.answers[key];
|
|
79
|
+
const correctLabel = formatWfsfCorrectLabel(correctAnswer);
|
|
86
80
|
const hasUserAnswer = userAnswer.trim().length > 0;
|
|
87
81
|
const isCorrect = hasUserAnswer &&
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const shouldShowReviewText = isReviewMode && !isCorrect && correctAnswer.trim().length > 0;
|
|
82
|
+
isWfsfBlankCorrect(userAnswer, correctAnswer, question.caseSensitive);
|
|
83
|
+
const shouldShowReviewText = isReviewMode && !isCorrect && correctLabel.length > 0;
|
|
91
84
|
input.value = shouldShowReviewText
|
|
92
|
-
? `${hasUserAnswer ? userAnswer : '(empty)'} → ${
|
|
85
|
+
? `${hasUserAnswer ? userAnswer : '(empty)'} → ${correctLabel}`
|
|
93
86
|
: userAnswer;
|
|
94
87
|
input.disabled = isReviewMode;
|
|
95
88
|
const blankStyle = question.blankStyle || 'word';
|
|
96
89
|
const isLetter = blankStyle === 'letter';
|
|
97
|
-
const isPhrase = blankStyle === 'phrase';
|
|
90
|
+
const isPhrase = blankStyle === 'phrase' || blankStyle === 'translate';
|
|
98
91
|
if (isLetter) {
|
|
99
|
-
|
|
92
|
+
const maxAltLen = expandWfsfCorrectAlts(correctAnswer).reduce((max, alt) => Math.max(max, alt.trim().length), 0);
|
|
93
|
+
input.maxLength = Math.max(1, maxAltLen || 1);
|
|
100
94
|
}
|
|
101
95
|
input.className = cn('ec-blank-input inline-block bg-blue-50/30 text-base font-semibold leading-tight text-[#0f1730] align-baseline outline-none transition-all hover:bg-blue-50/60 focus:bg-blue-50/80', isLetter
|
|
102
96
|
? 'h-8 w-8 rounded border-2 border-[#1167e8] px-0 text-center uppercase'
|
|
103
97
|
: isPhrase
|
|
104
|
-
? 'min-w-[
|
|
105
|
-
:
|
|
106
|
-
'min-w-[4.5rem] max-w-[7.5rem] w-[8ch] rounded-t border-0 border-b-2 border-dashed border-[#1167e8] px-1 py-0.5 text-center focus:border-solid focus:border-[#0b57d0]', isReviewMode
|
|
98
|
+
? 'min-w-[16rem] max-w-[36rem] w-[28ch] rounded-t border-0 border-b-2 border-dashed border-[#1167e8] px-2 py-0.5 text-center focus:border-solid focus:border-[#0b57d0]'
|
|
99
|
+
: 'min-w-[4.5rem] max-w-[8rem] w-[10ch] rounded-t border-0 border-b-2 border-dashed border-[#1167e8] px-1 py-0.5 text-center focus:border-solid focus:border-[#0b57d0]', isReviewMode
|
|
107
100
|
? isCorrect
|
|
108
101
|
? 'border-solid border-[#22c55e] bg-emerald-50 text-[#15803d]'
|
|
109
102
|
: hasUserAnswer
|
|
@@ -111,7 +104,7 @@ const StructuredFormBody = memo(function StructuredFormBody({ question, initialA
|
|
|
111
104
|
: 'border-solid border-[#f97316] bg-amber-50 text-[#c2410c]'
|
|
112
105
|
: '');
|
|
113
106
|
if (!isLetter) {
|
|
114
|
-
input.size = isPhrase ?
|
|
107
|
+
input.size = isPhrase ? 28 : 10;
|
|
115
108
|
}
|
|
116
109
|
// letter inline box — host FE may not scan Tailwind
|
|
117
110
|
if (isLetter) {
|
|
@@ -160,7 +153,7 @@ const StructuredFormBody = memo(function StructuredFormBody({ question, initialA
|
|
|
160
153
|
};
|
|
161
154
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
155
|
}, [processedHtml, isReviewMode]);
|
|
163
|
-
return (_jsxs("div", { className: "space-y-3", children: [question.title && (_jsx("h3", { className: "text-base font-semibold text-[#0f1730]", children: question.title })), question.imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "overflow-hidden rounded-lg border border-[#d9e4f2]", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: question.title || 'Question image', className: "max-h-56 w-auto object-contain" }) }) })), _jsx("div", { ref: containerRef, className: "ec-structured-content w-full overflow-x-auto rounded-xl border border-[#d9e4f2] bg-[#f4f9ff] p-5 text-base leading-relaxed text-[#0f1730] [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#d9e4f2] [&_td]:px-4 [&_td]:py-3 [&_td]:align-top [&_th]:border [&_th]:border-[#d9e4f2] [&_th]:bg-[#e9f3ff] [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-semibold" }), isReviewMode && Object.keys(question.answers).length > 0 && (_jsxs("div", { className: "rounded-xl border border-[#d9e4f2] bg-white/70 p-3", children: [_jsx("span", { className: "text-[10px] font-semibold uppercase tracking-wider text-[#64748b]", children: "Correct answers" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-[#b9edc8] bg-[#ddf8e7] px-2 py-0.5 text-xs font-semibold text-[#15803d]", children: [key, ": ", val] }, key))) })] }))] }));
|
|
156
|
+
return (_jsxs("div", { className: "space-y-3", children: [question.title && (_jsx("h3", { className: "text-base font-semibold text-[#0f1730]", children: question.title })), question.imageUrl && (_jsx("div", { className: "flex justify-center", children: _jsx("div", { className: "overflow-hidden rounded-lg border border-[#d9e4f2]", children: _jsx(ResolvedImage, { src: question.imageUrl, alt: question.title || 'Question image', className: "max-h-56 w-auto object-contain" }) }) })), _jsx("div", { ref: containerRef, className: "ec-structured-content w-full overflow-x-auto rounded-xl border border-[#d9e4f2] bg-[#f4f9ff] p-5 text-base leading-relaxed text-[#0f1730] [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:border-[#d9e4f2] [&_td]:px-4 [&_td]:py-3 [&_td]:align-top [&_th]:border [&_th]:border-[#d9e4f2] [&_th]:bg-[#e9f3ff] [&_th]:px-4 [&_th]:py-3 [&_th]:text-left [&_th]:font-semibold" }), isReviewMode && Object.keys(question.answers).length > 0 && (_jsxs("div", { className: "rounded-xl border border-[#d9e4f2] bg-white/70 p-3", children: [_jsx("span", { className: "text-[10px] font-semibold uppercase tracking-wider text-[#64748b]", children: "Correct answers" }), _jsx("div", { className: "mt-1 flex flex-wrap gap-1.5", children: Object.entries(question.answers).map(([key, val]) => (_jsxs("span", { className: "rounded border border-[#b9edc8] bg-[#ddf8e7] px-2 py-0.5 text-xs font-semibold text-[#15803d]", children: [key, ": ", formatWfsfCorrectLabel(val)] }, key))) })] }))] }));
|
|
164
157
|
});
|
|
165
158
|
/**
|
|
166
159
|
* Branded English Certification renderer for `WORD_FILL_STRUCTURED_FORM`.
|
package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js
CHANGED
|
@@ -13,6 +13,8 @@ import { EcMatchByWritingAnswer } from './EcMatchByWritingAnswer';
|
|
|
13
13
|
import { EcAnswerTheQuestion } from './EcAnswerTheQuestion';
|
|
14
14
|
import { EcWordFillParagraph } from './EcWordFillParagraph';
|
|
15
15
|
import { EcWritingTask } from './EcWritingTask';
|
|
16
|
+
import { MoversListenAndWriteRenderer } from '../../../components/exams/take/components/question-renderers/MoversListenAndWriteRenderer';
|
|
17
|
+
import { transformFillInBlank } from '../../../components/exams/take/utils/question-transformers';
|
|
16
18
|
/**
|
|
17
19
|
* Question types that the branded English Certification theme renders natively
|
|
18
20
|
* as multiple-choice cards (TOEIC + IELTS MCQ variants such as TRUE/FALSE/NOT GIVEN).
|
|
@@ -67,6 +69,12 @@ export function EnglishCertificationQuestionRenderer({ part, partConfig, answers
|
|
|
67
69
|
return (_jsx(EcAnswerTheQuestion, { part: part, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
68
70
|
case 'WORD_FILL_PARAGRAPH':
|
|
69
71
|
return (_jsx(EcWordFillParagraph, { part: part, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
72
|
+
case 'FILL_IN_BLANK': {
|
|
73
|
+
const data = transformFillInBlank(part.questions, {
|
|
74
|
+
suggestedAnswers: part.suggestedAnswers,
|
|
75
|
+
});
|
|
76
|
+
return (_jsx(MoversListenAndWriteRenderer, { partNumber: part.partNo ?? 0, partName: part.name, questionCount: part.questions.filter((q) => !q.is_example).length, instruction: part.instructions || data.instruction, title: data.title, imageUrl: data.groupImageUrl || data.imageUrl, audioUrl: data.audioUrl || part.audioUrl, exampleText: data.exampleText, exampleHighlight: data.exampleHighlight, questions: data.questions, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode, showHints: data.showHints, groupContent: data.groupContent, groupTitle: data.groupTitle, imageLabels: data.imageLabels }));
|
|
77
|
+
}
|
|
70
78
|
case 'WRITE_A_SHORT_LETTER':
|
|
71
79
|
return (_jsx(EcWritingTask, { part: part, answers: answers, onAnswerChange: onAnswerChange, isReviewMode: isReviewMode }));
|
|
72
80
|
default:
|
|
@@ -21,8 +21,8 @@ export declare const ROUTES: {
|
|
|
21
21
|
readonly USERS: "/manage/users";
|
|
22
22
|
readonly USERS_CREATE: "/manage/users/create";
|
|
23
23
|
readonly USERS_EDIT: (id: string) => string;
|
|
24
|
-
readonly QUESTION_BANK: "/manage/question-
|
|
25
|
-
readonly QUESTION_CREATE: "/manage/question-
|
|
24
|
+
readonly QUESTION_BANK: "/manage/question-banks";
|
|
25
|
+
readonly QUESTION_CREATE: "/manage/question-banks/create";
|
|
26
26
|
readonly QUESTION_EDIT: (id: string) => string;
|
|
27
27
|
readonly EXAMS: "/manage/exams";
|
|
28
28
|
readonly EXAMS_CREATE: "/manage/exams/create";
|
|
@@ -26,9 +26,9 @@ export const ROUTES = {
|
|
|
26
26
|
USERS: '/manage/users',
|
|
27
27
|
USERS_CREATE: '/manage/users/create',
|
|
28
28
|
USERS_EDIT: (id) => `/manage/users/${id}/edit`,
|
|
29
|
-
QUESTION_BANK: '/manage/question-
|
|
30
|
-
QUESTION_CREATE: '/manage/question-
|
|
31
|
-
QUESTION_EDIT: (id) => `/manage/question-
|
|
29
|
+
QUESTION_BANK: '/manage/question-banks',
|
|
30
|
+
QUESTION_CREATE: '/manage/question-banks/create',
|
|
31
|
+
QUESTION_EDIT: (id) => `/manage/question-banks/${id}/edit`,
|
|
32
32
|
EXAMS: '/manage/exams',
|
|
33
33
|
EXAMS_CREATE: '/manage/exams/create',
|
|
34
34
|
EXAMS_EDIT: (id) => `/manage/exams/${id}/edit`,
|
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
/** Strip HTML tags/entities so empty TipTap documents (`<p></p>`) count as blank. */
|
|
2
2
|
export declare function richTextToPlainText(value: string | null | undefined): string;
|
|
3
3
|
export declare function isRichTextEmpty(value: string | null | undefined): boolean;
|
|
4
|
+
/** True when HTML has marks/blocks beyond a plain paragraph (bold, list, table, …). */
|
|
5
|
+
export declare function hasRichTextFormatting(value: string | null | undefined): boolean;
|
|
6
|
+
/** Show stored TipTap HTML as editable plain text without collapsing line breaks. */
|
|
7
|
+
export declare function htmlToPlainEditorText(value: string | null | undefined): string;
|
|
@@ -22,3 +22,28 @@ export function richTextToPlainText(value) {
|
|
|
22
22
|
export function isRichTextEmpty(value) {
|
|
23
23
|
return richTextToPlainText(value) === '';
|
|
24
24
|
}
|
|
25
|
+
const SIMPLE_WRAPPER_TAG_RE = /<\/?(?:p|br)\s*\/?>/gi;
|
|
26
|
+
/** True when HTML has marks/blocks beyond a plain paragraph (bold, list, table, …). */
|
|
27
|
+
export function hasRichTextFormatting(value) {
|
|
28
|
+
if (!value)
|
|
29
|
+
return false;
|
|
30
|
+
return /<[^>]+>/.test(value.replace(SIMPLE_WRAPPER_TAG_RE, ''));
|
|
31
|
+
}
|
|
32
|
+
/** Show stored TipTap HTML as editable plain text without collapsing line breaks. */
|
|
33
|
+
export function htmlToPlainEditorText(value) {
|
|
34
|
+
if (!value)
|
|
35
|
+
return '';
|
|
36
|
+
if (!/<[a-z][\s\S]*>/i.test(value))
|
|
37
|
+
return value;
|
|
38
|
+
return value
|
|
39
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
40
|
+
.replace(/<\/p>\s*<p>/gi, '\n')
|
|
41
|
+
.replace(HTML_TAG_RE, '')
|
|
42
|
+
.replace(/ |&|<|>|"|'/gi, (entity) => {
|
|
43
|
+
return HTML_ENTITIES[entity.toLowerCase()] ?? entity;
|
|
44
|
+
})
|
|
45
|
+
.replace(/\u00a0/g, ' ')
|
|
46
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
47
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
48
|
+
.trim();
|
|
49
|
+
}
|
|
@@ -175,7 +175,7 @@ function resolveQuestionGroupPayload(sources) {
|
|
|
175
175
|
return passageData;
|
|
176
176
|
if (imageData)
|
|
177
177
|
return imageData;
|
|
178
|
-
if (basicGroupData && (basicGroupData.title || basicGroupData.subtitle || basicGroupData.content || basicGroupData.imageUrl || basicGroupData.articles)) {
|
|
178
|
+
if (basicGroupData && (basicGroupData.title || basicGroupData.subtitle || basicGroupData.content || basicGroupData.imageUrl || basicGroupData.articles || (basicGroupData.imageLabels && basicGroupData.imageLabels.length > 0))) {
|
|
179
179
|
return basicGroupData;
|
|
180
180
|
}
|
|
181
181
|
if (sceneData)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type WfsfCorrectValue = string | string[];
|
|
2
|
+
export declare function cleanWfsfWord(str: unknown, caseSensitive?: boolean): string;
|
|
3
|
+
export declare function expandWfsfCorrectAlts(correctVal: unknown): string[];
|
|
4
|
+
export declare function formatWfsfCorrectLabel(correctVal: unknown): string;
|
|
5
|
+
export declare function isWfsfBlankCorrect(userWord: string, correctVal: unknown, caseSensitive?: boolean): boolean;
|
|
6
|
+
export declare function normalizeWfsfAnswersMap(raw: unknown): Record<string, string[]>;
|
|
7
|
+
export declare function compactWfsfAnswersMap(answers: Record<string, string | string[]>): Record<string, string[]>;
|
|
8
|
+
export declare function primaryWfsfAnswers(answers: Record<string, string | string[]>): Record<string, string>;
|
|
9
|
+
export declare function hasWfsfBlankAnswer(value: unknown): boolean;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export function cleanWfsfWord(str, caseSensitive = false) {
|
|
2
|
+
const text = str == null ? '' : String(str);
|
|
3
|
+
const cleaned = text
|
|
4
|
+
.trim()
|
|
5
|
+
.replace(/[\p{P}]/gu, '')
|
|
6
|
+
.replace(/\s+/g, ' ')
|
|
7
|
+
.trim();
|
|
8
|
+
return caseSensitive ? cleaned : cleaned.toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
export function expandWfsfCorrectAlts(correctVal) {
|
|
11
|
+
if (Array.isArray(correctVal)) {
|
|
12
|
+
return correctVal.map((v) => String(v ?? '').trim()).filter(Boolean);
|
|
13
|
+
}
|
|
14
|
+
if (correctVal == null)
|
|
15
|
+
return [];
|
|
16
|
+
const valStr = String(correctVal);
|
|
17
|
+
if (valStr.includes('/')) {
|
|
18
|
+
return valStr.split('/').map((s) => s.trim()).filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
const trimmed = valStr.trim();
|
|
21
|
+
return trimmed ? [trimmed] : [];
|
|
22
|
+
}
|
|
23
|
+
export function formatWfsfCorrectLabel(correctVal) {
|
|
24
|
+
return expandWfsfCorrectAlts(correctVal).join(' / ');
|
|
25
|
+
}
|
|
26
|
+
export function isWfsfBlankCorrect(userWord, correctVal, caseSensitive = false) {
|
|
27
|
+
const userClean = cleanWfsfWord(userWord, caseSensitive);
|
|
28
|
+
if (!userClean)
|
|
29
|
+
return false;
|
|
30
|
+
return expandWfsfCorrectAlts(correctVal).some((alt) => cleanWfsfWord(alt, caseSensitive) === userClean);
|
|
31
|
+
}
|
|
32
|
+
export function normalizeWfsfAnswersMap(raw) {
|
|
33
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
34
|
+
return {};
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
37
|
+
const alts = expandWfsfCorrectAlts(value);
|
|
38
|
+
result[key] = alts.length > 0 ? alts : [''];
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
export function compactWfsfAnswersMap(answers) {
|
|
43
|
+
const result = {};
|
|
44
|
+
for (const [key, value] of Object.entries(answers)) {
|
|
45
|
+
const alts = expandWfsfCorrectAlts(value);
|
|
46
|
+
if (alts.length > 0)
|
|
47
|
+
result[key] = alts;
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
export function primaryWfsfAnswers(answers) {
|
|
52
|
+
const result = {};
|
|
53
|
+
for (const [key, value] of Object.entries(answers)) {
|
|
54
|
+
result[key] = expandWfsfCorrectAlts(value)[0] || '';
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
export function hasWfsfBlankAnswer(value) {
|
|
59
|
+
return expandWfsfCorrectAlts(value).length > 0;
|
|
60
|
+
}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|