@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.
Files changed (60) hide show
  1. package/dist/api/exam-taking.types.d.ts +1 -0
  2. package/dist/components/exams/ExamPreviewDialog.js +4 -2
  3. package/dist/components/exams/take/components/QuestionRenderer.js +2 -2
  4. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +3 -1
  5. package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +5 -2
  6. package/dist/components/exams/take/components/question-renderers/MoversWordFillStructuredFormRenderer.js +13 -20
  7. package/dist/components/exams/take/exam-taking.utils.d.ts +3 -2
  8. package/dist/components/exams/take/exam-taking.utils.js +3 -0
  9. package/dist/components/exams/take/utils/question-transformers.d.ts +11 -3
  10. package/dist/components/exams/take/utils/question-transformers.js +45 -2
  11. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.d.ts +6 -2
  12. package/dist/components/questions/_shared/hooks/useBasicQuestionGroup.js +26 -5
  13. package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +9 -0
  14. package/dist/components/questions/_shared/types/question-group.type.d.ts +11 -0
  15. package/dist/components/questions/_shared/types/question-group.type.js +4 -1
  16. package/dist/components/questions/_shared/types/spontaneous-qa.type.d.ts +7 -0
  17. package/dist/components/questions/_shared/types/word-fill-structured-form.type.d.ts +1 -1
  18. package/dist/components/questions/groups/ImageLabelGroupCard.d.ts +15 -0
  19. package/dist/components/questions/groups/ImageLabelGroupCard.js +42 -0
  20. package/dist/components/questions/groups/ImageLabelWordBank.d.ts +10 -0
  21. package/dist/components/questions/groups/ImageLabelWordBank.js +17 -0
  22. package/dist/components/questions/types/choose-the-correct-answer/ChooseTheCorrectAnswerCreator.js +33 -9
  23. package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +6 -2
  24. package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +28 -5
  25. package/dist/components/questions/types/fill-in-blank/register.js +5 -0
  26. package/dist/components/questions/types/fill-in-blank/transform.js +4 -1
  27. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.d.ts +11 -0
  28. package/dist/components/questions/types/speaking-cue-card/SpeakingCueCardClient.js +27 -0
  29. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.d.ts +2 -0
  30. package/dist/components/questions/types/spontaneous-qa/SpontaneousQAClient.js +15 -0
  31. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.d.ts +2 -0
  32. package/dist/components/questions/types/spontaneous-qa/map-spontaneous-qa-data.js +33 -0
  33. package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.d.ts +2 -2
  34. package/dist/components/questions/types/word-fill-structured-form/WfsfWordBankEditor.js +15 -5
  35. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +39 -15
  36. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormCreator.js +35 -12
  37. package/dist/components/questions/types/word-fill-structured-form/map-wfsf-question-data.js +2 -10
  38. package/dist/components/questions/types/word-fill-structured-form/transform.js +2 -1
  39. package/dist/components/questions/types/word-ordering/WordOrderingClient.js +51 -33
  40. package/dist/components/questions/viewer/QuestionViewer.js +12 -68
  41. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +3 -1
  42. package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +4 -2
  43. package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
  44. package/dist/components/themes/cambridge-yle/CambridgeYleInstructionBanner.js +12 -3
  45. package/dist/components/themes/english-certification/EcWordFillStructuredForm.js +13 -20
  46. package/dist/components/themes/english-certification/EnglishCertificationQuestionRenderer.js +8 -0
  47. package/dist/shared/constants/routes.d.ts +2 -2
  48. package/dist/shared/constants/routes.js +3 -3
  49. package/dist/shared/lib/rich-text.d.ts +4 -0
  50. package/dist/shared/lib/rich-text.js +25 -0
  51. package/dist/shared/lib/stores/examQuestionStore.d.ts +4 -0
  52. package/dist/shared/lib/utils/question-transform-types.d.ts +4 -0
  53. package/dist/shared/lib/utils/question-transform.js +1 -1
  54. package/dist/shared/lib/utils/wfsf-blank-match.d.ts +9 -0
  55. package/dist/shared/lib/utils/wfsf-blank-match.js +60 -0
  56. package/package.json +1 -1
  57. package/dist/components/themes/cambridge-yle/assets/images/bg-title-old.png +0 -0
  58. package/dist/components/themes/cambridge-yle/assets/images/border-old.png +0 -0
  59. package/dist/components/themes/cambridge-yle/assets/images/close_old.png +0 -0
  60. 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
- export function ReviewListenAndWriteRenderer({ partNumber, questionCount, instruction, partName, audioUrl, title, exampleText, questions, answers, isCorrectMap, }) {
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("p", { className: compact
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
- normalizeStructuredAnswer(userAnswer, question.caseSensitive) ===
89
- normalizeStructuredAnswer(correctAnswer, question.caseSensitive);
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)'} → ${correctAnswer}`
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
- input.maxLength = Math.max(1, (correctAnswer || ' ').trim().length || 1);
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-[9rem] max-w-[18rem] w-[14ch] rounded-t border-0 border-b-2 border-dashed border-[#1167e8] px-2 py-0.5 text-center focus:border-solid focus:border-[#0b57d0]'
105
- : // blankStyle=word compact for one token
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 ? 18 : 10;
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`.
@@ -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-bank";
25
- readonly QUESTION_CREATE: "/manage/question-bank/create";
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-bank',
30
- QUESTION_CREATE: '/manage/question-bank/create',
31
- QUESTION_EDIT: (id) => `/manage/question-bank/${id}/edit`,
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(/&nbsp;|&amp;|&lt;|&gt;|&quot;|&#39;/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
+ }
@@ -20,6 +20,10 @@ interface SharedPassageData {
20
20
  content: string;
21
21
  imageUrl: string;
22
22
  }>;
23
+ imageLabels?: Array<{
24
+ imageUrl: string;
25
+ label: string;
26
+ }>;
23
27
  }
24
28
  interface SharedSceneData {
25
29
  backgroundImageUrl?: string;
@@ -27,6 +27,10 @@ export type TransformResult = {
27
27
  content?: string;
28
28
  imageUrl?: string;
29
29
  articles?: any[];
30
+ imageLabels?: Array<{
31
+ imageUrl: string;
32
+ label: string;
33
+ }>;
30
34
  };
31
35
  questionListGroupData?: {
32
36
  questions: Array<{
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",