@tinyweb_dev/oe-exam-sdk 0.2.12 → 0.2.13

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 (34) hide show
  1. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +1 -1
  2. package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +2 -2
  3. package/dist/components/exams/take/utils/question-transformers.d.ts +1 -1
  4. package/dist/components/questions/_shared/types/answer-the-question-group.type.d.ts +3 -0
  5. package/dist/components/questions/_shared/types/answer-the-question-group.type.js +12 -1
  6. package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +14 -2
  7. package/dist/components/questions/_shared/types/match-word-to-picture-group.type.d.ts +4 -1
  8. package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +9 -2
  9. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupClient.js +12 -1
  10. package/dist/components/questions/types/answer-the-question-group/AnswerTheQuestionGroupCreator.js +6 -2
  11. package/dist/components/questions/types/answer-the-question-group/map-answer-the-question-group-data.js +5 -0
  12. package/dist/components/questions/types/answer-the-question-group/transform.js +3 -0
  13. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +51 -16
  14. package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +52 -20
  15. package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +23 -0
  16. package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +89 -0
  17. package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +24 -6
  18. package/dist/components/questions/types/fill-in-blank-group/transform.js +18 -3
  19. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupClient.js +6 -6
  20. package/dist/components/questions/types/match-word-to-picture-group/MatchWordToPictureGroupCreator.js +15 -7
  21. package/dist/components/questions/types/match-word-to-picture-group/map-match-word-to-picture-group-data.js +4 -0
  22. package/dist/components/questions/types/match-word-to-picture-group/transform.js +9 -1
  23. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +4 -2
  24. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +4 -4
  25. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +13 -2
  26. package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +25 -13
  27. package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +19 -2
  28. package/dist/components/questions/types/matching-with-lines-group/transform.js +29 -13
  29. package/dist/components/questions/types/word-fill-structured-form/WordFillStructuredFormClient.js +18 -0
  30. package/dist/shared/lib/utils/question-reverse-transform.js +29 -5
  31. package/dist/shared/types/questions/fill-in-blank-group.d.ts +9 -2
  32. package/dist/shared/types/questions/match-word-to-picture-group.d.ts +3 -1
  33. package/dist/shared/types/questions/matching-with-lines-group.d.ts +7 -2
  34. package/package.json +1 -1
@@ -10,6 +10,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '../../../../components
10
10
  import { Switch } from '../../../../components/ui/switch';
11
11
  import { useDebouncedCallback } from '../../../../shared/lib/hooks';
12
12
  import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
13
+ import { RichTextEditor } from '../../../../components/shared/RichTextEditor';
13
14
  import { MatchingWithLinesGroupClient } from './MatchingWithLinesGroupClient';
14
15
  import { MatchingWithLinesImageField } from './MatchingWithLinesImageField';
15
16
  import { createEmptyMatchingPair, nextRightId } from './map-matching-with-lines-group-data';
@@ -32,13 +33,15 @@ function normalizeRightItems(raw) {
32
33
  }
33
34
  return raw.map((item, index) => ({
34
35
  id: item.id || `r${index + 1}`,
35
- text: item.text || '',
36
+ ...(item.text?.trim() ? { text: item.text.trim() } : {}),
37
+ ...(item.imageUrl?.trim() ? { imageUrl: item.imageUrl.trim() } : {}),
36
38
  }));
37
39
  }
38
40
  function normalizeItems(rawItems, rightItems) {
39
41
  if (!Array.isArray(rawItems) || rawItems.length === 0) {
40
42
  return rightItems.map((right, index) => ({
41
43
  imageUrl: '',
44
+ text: '',
42
45
  correctAnswer: right.id,
43
46
  isExample: false,
44
47
  points: 1,
@@ -47,6 +50,7 @@ function normalizeItems(rawItems, rightItems) {
47
50
  }
48
51
  return rawItems.map((item, index) => ({
49
52
  imageUrl: item.imageUrl || '',
53
+ text: item.text || '',
50
54
  correctAnswer: item.correctAnswer || rightItems[index]?.id || '',
51
55
  isExample: item.isExample === true,
52
56
  points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
@@ -61,6 +65,9 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
61
65
  const [instruction, setInstruction] = useState(initialData?.instruction || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION);
62
66
  const [rightItems, setRightItems] = useState(() => normalizeRightItems(initialData?.rightItems));
63
67
  const [items, setItems] = useState(() => normalizeItems(initialData?.items, normalizeRightItems(initialData?.rightItems)));
68
+ const [passage, setPassage] = useState(initialData?.passage || '');
69
+ const [audioUrl] = useState(initialData?.audioUrl || '');
70
+ const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
64
71
  const [explanation, setExplanation] = useState(initialData?.explanation || '');
65
72
  const [isClientMode, setIsClientMode] = useState(false);
66
73
  const [errors, setErrors] = useState([]);
@@ -69,9 +76,12 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
69
76
  useEffect(() => {
70
77
  onChangeRef.current = onChange;
71
78
  }, [onChange]);
72
- const buildPayload = (nextTitle = title, nextInstruction = instruction, nextRightItems = rightItems, nextItems = items, nextExplanation = explanation) => ({
79
+ const buildPayload = (nextTitle = title, nextInstruction = instruction, nextPassage = passage, nextRightItems = rightItems, nextItems = items, nextImageUrl = imageUrl, nextExplanation = explanation) => ({
73
80
  title: nextTitle,
74
81
  instruction: nextInstruction,
82
+ ...(nextPassage.trim() ? { passage: nextPassage } : {}),
83
+ ...(audioUrl ? { audioUrl } : {}),
84
+ ...(nextImageUrl.trim() ? { imageUrl: nextImageUrl } : {}),
75
85
  rightItems: nextRightItems,
76
86
  items: nextItems,
77
87
  explanation: nextExplanation,
@@ -88,7 +98,7 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
88
98
  debouncedOnChange(payload);
89
99
  onUnsavedChangesChange?.(true);
90
100
  }
91
- }, [title, instruction, rightItems, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
101
+ }, [title, instruction, passage, audioUrl, imageUrl, rightItems, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
92
102
  const validate = () => {
93
103
  const nextErrors = [];
94
104
  if (!instruction.trim()) {
@@ -98,14 +108,15 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
98
108
  nextErrors.push('Cần ít nhất 2 cặp để nối.');
99
109
  }
100
110
  if (items.length !== rightItems.length) {
101
- nextErrors.push('Số ảnh (cột trái) phải bằng số từ (cột phải).');
111
+ nextErrors.push('Số ô cột trái phải bằng số ô cột phải.');
102
112
  }
103
113
  const ids = new Set();
104
114
  rightItems.forEach((item, index) => {
105
115
  if (!item.id.trim())
106
116
  nextErrors.push(`Từ ${index + 1}: id không được để trống.`);
107
- if (!item.text.trim())
108
- nextErrors.push(`Từ ${index + 1}: nội dung không được để trống.`);
117
+ if (!item.text?.trim() && !item.imageUrl?.trim()) {
118
+ nextErrors.push(`Cột phải ${index + 1}: cần từ hoặc ảnh.`);
119
+ }
109
120
  if (item.id.trim() && ids.has(item.id.trim())) {
110
121
  nextErrors.push(`Từ ${index + 1}: id "${item.id}" bị trùng.`);
111
122
  }
@@ -113,12 +124,13 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
113
124
  });
114
125
  const usedAnswers = new Set();
115
126
  items.forEach((item, index) => {
116
- if (!item.imageUrl.trim())
117
- nextErrors.push(`Ảnh ${index + 1}: cần upload hình.`);
127
+ if (!item.imageUrl?.trim() && !item.text?.trim()) {
128
+ nextErrors.push(`Cột trái ${index + 1}: cần ảnh hoặc chữ.`);
129
+ }
118
130
  if (!item.correctAnswer.trim())
119
- nextErrors.push(`Ảnh ${index + 1}: chưa chọn đáp án.`);
131
+ nextErrors.push(`Cột trái ${index + 1}: chưa chọn đáp án.`);
120
132
  if (item.correctAnswer && usedAnswers.has(item.correctAnswer)) {
121
- nextErrors.push(`Ảnh ${index + 1}: đáp án bị trùng (mỗi từ chỉ dùng 1 lần).`);
133
+ nextErrors.push(`Cột trái ${index + 1}: đáp án bị trùng (mỗi từ chỉ dùng 1 lần).`);
122
134
  }
123
135
  usedAnswers.add(item.correctAnswer);
124
136
  });
@@ -132,7 +144,7 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
132
144
  getFormData: () => buildPayload(),
133
145
  };
134
146
  }
135
- }, [validationRef, title, instruction, rightItems, items, explanation]);
147
+ }, [validationRef, title, instruction, passage, audioUrl, imageUrl, rightItems, items, explanation]);
136
148
  const updateItem = (index, patch) => {
137
149
  setItems((prev) => {
138
150
  const next = [...prev];
@@ -174,7 +186,7 @@ export function MatchingWithLinesGroupCreator({ initialData, onChange, externalE
174
186
  };
175
187
  const currentPayload = buildPayload();
176
188
  const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
177
- return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi N\u1ED1i b\u1EB1ng \u0111\u01B0\u1EDDng k\u1EBB (Nh\u00F3m)" }), _jsx("p", { className: "text-sm text-gray-500", children: "N\u1ED1i \u1EA3nh c\u1ED9t tr\u00E1i v\u1EDBi t\u1EEB c\u1ED9t ph\u1EA3i. M\u1ED7i t\u1EEB d\u00F9ng \u0111\u00FAng 1 l\u1EA7n." })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4", children: [_jsx("h3", { className: "text-sm font-medium text-red-800", children: "C\u1EA7n s\u1EEDa c\u00E1c l\u1ED7i sau:" }), _jsx("ul", { className: "mt-2 list-disc space-y-1 pl-5 text-sm text-red-700", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) })] })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(MatchingWithLinesGroupClient, { questionData: currentPayload, userAnswers: currentPayload.items.map((item) => item.correctAnswer) }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "mwl-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwl-title", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY", className: "mt-1" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "mwl-instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "mwl-instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["C\u1EB7p n\u1ED1i (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addPair, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u1EB7p"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-4 rounded-lg border border-gray-200 bg-gray-50/50 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u1EB7p ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `mwl-example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `mwl-example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
189
+ return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { children: [_jsx("h3", { className: "text-lg font-medium text-gray-900", children: "T\u1EA1o c\u00E2u h\u1ECFi N\u1ED1i b\u1EB1ng \u0111\u01B0\u1EDDng k\u1EBB (Nh\u00F3m)" }), _jsx("p", { className: "text-sm text-gray-500", children: "N\u1ED1i \u1EA3nh c\u1ED9t tr\u00E1i v\u1EDBi t\u1EEB c\u1ED9t ph\u1EA3i. M\u1ED7i t\u1EEB d\u00F9ng \u0111\u00FAng 1 l\u1EA7n." })] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: () => setIsClientMode((prev) => !prev), className: "gap-2", children: [isClientMode ? _jsx(Pencil, { className: "h-4 w-4" }) : _jsx(Eye, { className: "h-4 w-4" }), isClientMode ? 'Chế độ chỉnh sửa' : 'Xem trước học sinh'] })] }), displayErrors.length > 0 && (_jsxs("div", { className: "rounded-lg border border-red-200 bg-red-50 p-4", children: [_jsx("h3", { className: "text-sm font-medium text-red-800", children: "C\u1EA7n s\u1EEDa c\u00E1c l\u1ED7i sau:" }), _jsx("ul", { className: "mt-2 list-disc space-y-1 pl-5 text-sm text-red-700", children: displayErrors.map((err, i) => (_jsx("li", { children: err }, i))) })] })), isClientMode ? (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "Xem tr\u01B0\u1EDBc hi\u1EC3n th\u1ECB h\u1ECDc sinh" }) }), _jsx(CardContent, { children: _jsx(MatchingWithLinesGroupClient, { questionData: currentPayload, userAnswers: currentPayload.items.map((item) => item.correctAnswer) }) })] })) : (_jsxs("div", { className: "space-y-6", children: [_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base font-semibold", children: "H\u01B0\u1EDBng d\u1EABn chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "mwl-title", children: "Ti\u00EAu \u0111\u1EC1 (optional)" }), _jsx(Input, { id: "mwl-title", value: title, onChange: (e) => setTitle(e.target.value), placeholder: "VOCABULARY", className: "mt-1" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: "mwl-instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "mwl-instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] }), _jsxs("div", { children: [_jsx(Label, { children: "N\u1ED9i dung b\u00E0i \u0111\u1ECDc (Passage)" }), _jsx("div", { className: "mt-1", children: _jsx(RichTextEditor, { value: passage, onChange: setPassage, minHeightClassName: "min-h-[160px]" }) })] }), _jsx(MatchingWithLinesImageField, { id: "mwl-group-image", label: "\u1EA2nh chung (optional)", value: imageUrl, onChange: setImageUrl })] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["C\u1EB7p n\u1ED1i (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addPair, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u1EB7p"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-4 rounded-lg border border-gray-200 bg-gray-50/50 p-4", children: [_jsxs("div", { className: "flex items-center justify-between gap-4", children: [_jsxs("span", { className: "text-sm font-medium text-gray-700", children: ["C\u1EB7p ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `mwl-example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `mwl-example-${index}`, className: "flex cursor-pointer items-center gap-1 text-xs", children: [_jsx(Star, { className: "h-3 w-3 text-amber-500" }), "Example"] })] }), !item.isExample && (_jsx("div", { className: "w-24", children: _jsx(PointsInput, { value: item.points, allowZero: true, onChange: (event) => updateItem(index, {
178
190
  points: parsePointsValue(event.target.value, true),
179
- }) }) })), items.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removePair(index), className: "text-red-500 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(MatchingWithLinesImageField, { id: `mwl-image-${index}`, label: "\u1EA2nh c\u1ED9t tr\u00E1i", value: item.imageUrl, onChange: (url) => updateItem(index, { imageUrl: url }) }), _jsxs("div", { className: "space-y-3", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-right-text-${index}`, children: "T\u1EEB c\u1ED9t ph\u1EA3i" }), _jsx(Input, { id: `mwl-right-text-${index}`, value: rightItems[index]?.text || '', onChange: (e) => updateRightItem(index, { text: e.target.value }), placeholder: "paintbrush", className: "mt-1 bg-white" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-answer-${index}`, children: "\u0110\u00E1p \u00E1n \u0111\u00FAng (id t\u1EEB)" }), _jsxs("select", { id: `mwl-answer-${index}`, value: item.correctAnswer, onChange: (e) => updateItem(index, { correctAnswer: e.target.value }), className: "mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm", children: [_jsx("option", { value: "", children: "Ch\u1ECDn t\u1EEB \u0111\u00FAng" }), rightItems.map((right) => (_jsxs("option", { value: right.id, children: [right.text || right.id, " (", right.id, ")"] }, right.id)))] })] })] })] })] }, `${rightItems[index]?.id || index}`))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { id: "explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3 }) })] })] }))] }));
191
+ }) }) })), items.length > 2 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removePair(index), className: "text-red-500 hover:bg-red-50 hover:text-red-600", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsxs("div", { className: "space-y-3", children: [_jsx(MatchingWithLinesImageField, { id: `mwl-image-${index}`, label: "\u1EA2nh c\u1ED9t tr\u00E1i", value: item.imageUrl || '', onChange: (url) => updateItem(index, { imageUrl: url }) }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-left-text-${index}`, children: "Ch\u1EEF c\u1ED9t tr\u00E1i (text-to-text)" }), _jsx(Input, { id: `mwl-left-text-${index}`, value: item.text || '', onChange: (e) => updateItem(index, { text: e.target.value }), placeholder: "ball", className: "mt-1 bg-white" })] })] }), _jsxs("div", { className: "space-y-3", children: [_jsx(MatchingWithLinesImageField, { id: `mwl-right-image-${index}`, label: "\u1EA2nh c\u1ED9t ph\u1EA3i (image-to-image)", value: rightItems[index]?.imageUrl || '', onChange: (url) => updateRightItem(index, { imageUrl: url || undefined }) }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-right-text-${index}`, children: "T\u1EEB c\u1ED9t ph\u1EA3i" }), _jsx(Input, { id: `mwl-right-text-${index}`, value: rightItems[index]?.text || '', onChange: (e) => updateRightItem(index, { text: e.target.value }), placeholder: "paintbrush", className: "mt-1 bg-white" })] }), _jsxs("div", { children: [_jsx(Label, { htmlFor: `mwl-answer-${index}`, children: "\u0110\u00E1p \u00E1n \u0111\u00FAng (id t\u1EEB)" }), _jsxs("select", { id: `mwl-answer-${index}`, value: item.correctAnswer, onChange: (e) => updateItem(index, { correctAnswer: e.target.value }), className: "mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm", children: [_jsx("option", { value: "", children: "Ch\u1ECDn t\u1EEB \u0111\u00FAng" }), rightItems.map((right) => (_jsxs("option", { value: right.id, children: [right.text || right.id, " (", right.id, ")"] }, right.id)))] })] })] })] })] }, `${rightItems[index]?.id || index}`))) })] }), _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-base", children: "Gi\u1EA3i th\u00EDch" }) }), _jsx(CardContent, { children: _jsx(Textarea, { id: "explanation", value: explanation, onChange: (event) => setExplanation(event.target.value), placeholder: "VD: C\u00E2u tr\u1EA3 l\u1EDDi \u0111\u00FAng l\u00E0... v\u00EC...", rows: 3 }) })] })] }))] }));
180
192
  }
@@ -17,9 +17,10 @@ export function nextRightId(existing) {
17
17
  }
18
18
  export function createEmptyMatchingPair(questionNumber, rightId) {
19
19
  return {
20
- rightItem: { id: rightId, text: '' },
20
+ rightItem: { id: rightId },
21
21
  item: {
22
22
  imageUrl: '',
23
+ text: '',
23
24
  correctAnswer: rightId,
24
25
  isExample: false,
25
26
  points: 1,
@@ -36,6 +37,7 @@ export function mapMatchingWithLinesGroupItem(item, index, fallbackAnswer) {
36
37
  (typeof fallbackAnswer === 'string' ? fallbackAnswer : '');
37
38
  return {
38
39
  imageUrl: asString(content.imageUrl) || asString(record.imageUrl),
40
+ text: asString(content.text) || asString(record.text),
39
41
  correctAnswer: answer,
40
42
  isExample,
41
43
  points: isExample ? 0 : (typeof record.points === 'number' ? record.points : 1),
@@ -47,9 +49,12 @@ function mapRightItems(raw) {
47
49
  return [];
48
50
  return raw.map((item, index) => {
49
51
  const record = asRecord(item);
52
+ const imageUrl = asString(record.imageUrl).trim();
53
+ const text = asString(record.text).trim();
50
54
  return {
51
55
  id: asString(record.id, `r${index + 1}`),
52
- text: asString(record.text),
56
+ ...(text ? { text } : {}),
57
+ ...(imageUrl ? { imageUrl } : {}),
53
58
  };
54
59
  });
55
60
  }
@@ -67,11 +72,17 @@ export function mapQuestionToMatchingWithLinesGroupData(question) {
67
72
  const meta = asRecord(content.meta ?? answer.meta);
68
73
  if (Array.isArray(content.items)) {
69
74
  const items = content.items.map((item, index) => mapMatchingWithLinesGroupItem(item, index, fallbackAnswers[index]));
75
+ const audioUrl = asString(meta.audioUrl).trim() || asString(answer.audioUrl).trim();
76
+ const passage = asString(meta.passage).trim() || asString(answer.passage).trim();
77
+ const imageUrl = asString(meta.imageUrl).trim() || asString(answer.imageUrl).trim();
70
78
  return {
71
79
  title: asString(meta.title) || asString(answer.title),
72
80
  instruction: asString(meta.instruction) ||
73
81
  asString(answer.instruction) ||
74
82
  MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
83
+ ...(passage ? { passage } : {}),
84
+ ...(audioUrl ? { audioUrl } : {}),
85
+ ...(imageUrl ? { imageUrl } : {}),
75
86
  rightItems: mapRightItems(content.rightItems),
76
87
  items,
77
88
  explanation,
@@ -80,9 +91,15 @@ export function mapQuestionToMatchingWithLinesGroupData(question) {
80
91
  }
81
92
  if (Array.isArray(answer.items)) {
82
93
  const items = answer.items.map((item, index) => mapMatchingWithLinesGroupItem(item, index, fallbackAnswers[index]));
94
+ const audioUrl = asString(answer.audioUrl).trim();
95
+ const passage = asString(answer.passage).trim();
96
+ const imageUrl = asString(answer.imageUrl).trim();
83
97
  return {
84
98
  title: asString(answer.title),
85
99
  instruction: asString(answer.instruction) || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
100
+ ...(passage ? { passage } : {}),
101
+ ...(audioUrl ? { audioUrl } : {}),
102
+ ...(imageUrl ? { imageUrl } : {}),
86
103
  rightItems: mapRightItems(answer.rightItems),
87
104
  items,
88
105
  explanation,
@@ -14,26 +14,42 @@ export const transformMatchingWithLinesGroup = (question) => {
14
14
  if (items.length === 0) {
15
15
  return { apiContent: { rightItems: [], items: [] }, apiCorrectAnswer: { answer: [] } };
16
16
  }
17
- const apiItems = items.map((item, index) => ({
18
- ...(item.isExample ? { isExample: true } : {}),
19
- content: {
20
- imageUrl: item.imageUrl || '',
21
- },
22
- correctAnswer: { answer: item.correctAnswer || '' },
23
- points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
24
- questionNumber: item.questionNumber || index + 1,
25
- }));
17
+ const apiItems = items.map((item, index) => {
18
+ const imageUrl = item.imageUrl?.trim() || '';
19
+ const text = item.text?.trim() || '';
20
+ return {
21
+ ...(item.isExample ? { isExample: true } : {}),
22
+ content: {
23
+ ...(imageUrl ? { imageUrl } : {}),
24
+ ...(text ? { text } : {}),
25
+ },
26
+ correctAnswer: { answer: item.correctAnswer || '' },
27
+ points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
28
+ questionNumber: item.questionNumber || index + 1,
29
+ };
30
+ });
26
31
  const title = typeof answerData?.title === 'string' ? answerData.title.trim() : '';
32
+ const passage = typeof answerData?.passage === 'string' ? answerData.passage.trim() : '';
33
+ const audioUrl = typeof answerData?.audioUrl === 'string' ? answerData.audioUrl.trim() : '';
34
+ const imageUrl = typeof answerData?.imageUrl === 'string' ? answerData.imageUrl.trim() : '';
27
35
  return {
28
36
  apiContent: {
29
37
  meta: {
30
38
  instruction: answerData?.instruction || MATCHING_WITH_LINES_GROUP_DEFAULT_INSTRUCTION,
31
39
  ...(title ? { title } : {}),
40
+ ...(passage ? { passage } : {}),
41
+ ...(audioUrl ? { audioUrl } : {}),
42
+ ...(imageUrl ? { imageUrl } : {}),
32
43
  },
33
- rightItems: rightItems.map((item) => ({
34
- id: item.id || '',
35
- text: item.text || '',
36
- })),
44
+ rightItems: rightItems.map((item) => {
45
+ const imageUrl = item.imageUrl?.trim() || '';
46
+ const text = item.text?.trim() || '';
47
+ return {
48
+ id: item.id || '',
49
+ ...(text ? { text } : {}),
50
+ ...(imageUrl ? { imageUrl } : {}),
51
+ };
52
+ }),
37
53
  items: apiItems,
38
54
  },
39
55
  apiCorrectAnswer: {
@@ -35,6 +35,20 @@ function ClientImageItem({ url, alt }) {
35
35
  return null;
36
36
  return (_jsx("div", { className: "overflow-hidden rounded-lg border border-gray-200 bg-gray-50 max-w-sm", children: isLoading ? (_jsx("div", { className: "flex h-36 w-48 items-center justify-center", children: _jsx("div", { className: "h-6 w-6 animate-spin rounded-full border-2 border-gray-200 border-t-teal-500" }) })) : (_jsx("img", { src: src, alt: alt || 'Image', className: "max-h-56 w-auto object-contain p-2" })) }));
37
37
  }
38
+ function WfsfHtmlImage({ src, alt, className, style, }) {
39
+ const { previewUrl, isLoading } = usePresignedFileUrl(src);
40
+ const resolved = previewUrl ||
41
+ (typeof src === 'string'
42
+ && (src.startsWith('http') || src.startsWith('blob:') || src.startsWith('data:'))
43
+ ? src
44
+ : '');
45
+ if (isLoading && !resolved) {
46
+ return _jsx("span", { className: "inline-block h-12 w-12 animate-pulse rounded bg-slate-100" });
47
+ }
48
+ if (!resolved)
49
+ return null;
50
+ return (_jsx("img", { src: resolved, alt: alt || '', className: className, style: style }));
51
+ }
38
52
  function ClientAudioItem({ url }) {
39
53
  const { previewUrl } = usePresignedFileUrl(url);
40
54
  const src = previewUrl ||
@@ -214,6 +228,10 @@ export function WordFillStructuredFormClient({ questionData, isReviewMode = fals
214
228
  }
215
229
  const element = node;
216
230
  const tagName = element.tagName.toLowerCase();
231
+ if (tagName === 'img') {
232
+ const rawStyle = element.getAttribute('style');
233
+ return (_jsx(WfsfHtmlImage, { src: element.getAttribute('src') || '', alt: element.getAttribute('alt') || '', className: element.getAttribute('class') || undefined, style: rawStyle ? parseStyleAttribute(rawStyle) : undefined }, path));
234
+ }
217
235
  const props = { key: path };
218
236
  Array.from(element.attributes).forEach((attr) => {
219
237
  if (attr.name === 'class') {
@@ -1,3 +1,4 @@
1
+ import { fromFillInBlankImageUrls, toFillInBlankImageUrls, } from '../../../components/questions/types/fill-in-blank-group/blank-utils';
1
2
  const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
2
3
  const asRecord = (value) => isRecord(value) ? value : {};
3
4
  const asArray = (value) => Array.isArray(value) ? value : [];
@@ -309,11 +310,18 @@ const reverseAnswerTheQuestionGroup = (apiQuestion) => {
309
310
  questionNumber: asNumber(item.questionNumber, index + 1),
310
311
  };
311
312
  });
313
+ const meta = asRecord(content.meta);
314
+ const imageUrl = asArray(meta.imageUrl)
315
+ .filter((value) => typeof value === 'string')
316
+ .map((value) => value.trim())
317
+ .filter((value) => value !== '');
318
+ const singleImage = asString(meta.imageUrl).trim();
312
319
  return {
313
320
  content: '',
314
321
  answer: {
315
- instruction: asString(asRecord(content.meta).instruction, 'Read the text again and answer the questions.'),
316
- passage: asString(asRecord(content.meta).passage),
322
+ instruction: asString(meta.instruction, 'Read the text again and answer the questions.'),
323
+ passage: asString(meta.passage),
324
+ ...(imageUrl.length ? { imageUrl } : (singleImage ? { imageUrl: [singleImage] } : {})),
317
325
  items,
318
326
  explanation: asString(apiQuestion.explanation),
319
327
  points: items.reduce((total, item) => (item.isExample ? total : total + item.points), 0),
@@ -402,27 +410,34 @@ const reverseFillInBlankGroup = (apiQuestion) => {
402
410
  : [String(blank ?? '').trim()].filter(Boolean);
403
411
  return alts.length > 0 ? alts : [''];
404
412
  });
413
+ const wordBank = asArray(itemContent.wordBank);
405
414
  return {
406
415
  question,
407
416
  answers: answers.length > 0 ? answers : [['']],
408
417
  caseSensitive: itemAnswer.caseSensitive === true || fallback.caseSensitive === true,
409
- imageUrl: asString(itemContent.imageUrl),
418
+ imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(itemContent.imageUrl)),
410
419
  audioUrl: asString(itemContent.audioUrl),
411
420
  showHints: itemContent.showHints === true,
412
421
  hints: asArray(itemContent.hints).map((hint) => String(hint ?? '').trim()).filter(Boolean),
422
+ ...(wordBank.length > 0 ? { wordBank } : {}),
413
423
  isExample,
414
424
  points: isExample ? 0 : asNumber(item.points, 1),
415
425
  questionNumber: asNumber(item.questionNumber, index + 1),
416
426
  };
417
427
  });
428
+ const groupWordBank = asArray(meta.wordBank);
429
+ const viewMode = asString(meta.viewMode) === 'WITH_OPTIONS' ? 'WITH_OPTIONS' : undefined;
418
430
  return {
419
431
  content: '',
420
432
  answer: {
421
433
  instruction: asString(meta.instruction, 'Complete the sentences.'),
434
+ passage: asString(meta.passage),
422
435
  audioUrl: asString(meta.audioUrl),
423
- imageUrl: asString(meta.imageUrl),
436
+ imageUrl: fromFillInBlankImageUrls(toFillInBlankImageUrls(meta.imageUrl)),
424
437
  showHints: meta.showHints === true,
425
438
  hints: asArray(meta.hints).map((hint) => String(hint ?? '').trim()).filter(Boolean),
439
+ ...(viewMode ? { viewMode } : {}),
440
+ ...(groupWordBank.length > 0 ? { wordBank: groupWordBank } : {}),
426
441
  items,
427
442
  explanation: asString(apiQuestion.explanation),
428
443
  points: items.reduce((total, item) => (item.isExample ? total : total + item.points), 0),
@@ -436,9 +451,12 @@ const reverseMatchingWithLinesGroup = (apiQuestion) => {
436
451
  const meta = asRecord(content.meta);
437
452
  const rightItems = asArray(content.rightItems).map((raw, index) => {
438
453
  const item = asRecord(raw);
454
+ const imageUrl = asString(item.imageUrl).trim();
455
+ const text = asString(item.text).trim();
439
456
  return {
440
457
  id: asString(item.id, `r${index + 1}`),
441
- text: asString(item.text),
458
+ ...(text ? { text } : {}),
459
+ ...(imageUrl ? { imageUrl } : {}),
442
460
  };
443
461
  });
444
462
  const items = asArray(content.items).map((rawItem, index) => {
@@ -449,6 +467,7 @@ const reverseMatchingWithLinesGroup = (apiQuestion) => {
449
467
  const isExample = item.isExample === true;
450
468
  return {
451
469
  imageUrl: asString(itemContent.imageUrl),
470
+ text: asString(itemContent.text),
452
471
  correctAnswer: asString(itemAnswer.answer, typeof fallback === 'string' ? fallback : ''),
453
472
  isExample,
454
473
  points: isExample ? 0 : asNumber(item.points, 1),
@@ -460,6 +479,9 @@ const reverseMatchingWithLinesGroup = (apiQuestion) => {
460
479
  answer: {
461
480
  title: asString(meta.title),
462
481
  instruction: asString(meta.instruction, 'Look and match.'),
482
+ ...(asString(meta.passage).trim() ? { passage: asString(meta.passage).trim() } : {}),
483
+ ...(asString(meta.audioUrl).trim() ? { audioUrl: asString(meta.audioUrl).trim() } : {}),
484
+ ...(asString(meta.imageUrl).trim() ? { imageUrl: asString(meta.imageUrl).trim() } : {}),
463
485
  rightItems,
464
486
  items,
465
487
  explanation: asString(apiQuestion.explanation),
@@ -669,7 +691,9 @@ const reverseMatchWordToPictureGroup = (apiQuestion) => {
669
691
  return {
670
692
  content: '',
671
693
  answer: {
694
+ ...(asString(meta.title).trim() ? { title: asString(meta.title).trim() } : {}),
672
695
  instruction: asString(meta.instruction, 'Look and complete.'),
696
+ ...(asString(meta.imageUrl).trim() ? { imageUrl: asString(meta.imageUrl).trim() } : {}),
673
697
  wordBank: asArray(meta.wordBank).map((word) => asString(word)).filter(Boolean),
674
698
  items: mappedItems,
675
699
  explanation: asString(apiQuestion.explanation),
@@ -3,12 +3,16 @@ export interface FillInBlankGroupItemAnswer {
3
3
  answers: string[][];
4
4
  caseSensitive?: boolean;
5
5
  }
6
+ export type FillInBlankWordBankEntry = {
7
+ options: string[];
8
+ } | null;
6
9
  export interface FillInBlankGroupItemContent {
7
10
  question: string;
8
- imageUrl?: string;
11
+ imageUrl?: string | string[];
9
12
  audioUrl?: string;
10
13
  showHints?: boolean;
11
14
  hints?: string[];
15
+ wordBank?: FillInBlankWordBankEntry[];
12
16
  }
13
17
  export interface FillInBlankGroupItem {
14
18
  questionType?: typeof FILL_IN_BLANK_ITEM_TYPE;
@@ -21,10 +25,13 @@ export interface FillInBlankGroupItem {
21
25
  export interface FillInBlankGroupContent {
22
26
  meta?: {
23
27
  instruction?: string;
28
+ passage?: string;
24
29
  audioUrl?: string;
25
- imageUrl?: string;
30
+ imageUrl?: string | string[];
26
31
  showHints?: boolean;
27
32
  hints?: string[];
33
+ viewMode?: 'WITH_OPTIONS';
34
+ wordBank?: FillInBlankWordBankEntry[];
28
35
  };
29
36
  items: FillInBlankGroupItem[];
30
37
  }
@@ -3,7 +3,7 @@ export interface MatchWordToPictureGroupItem {
3
3
  questionType?: typeof MATCH_WORD_TO_PICTURE_ITEM_TYPE;
4
4
  isExample?: boolean;
5
5
  content: {
6
- imageUrl: string;
6
+ imageUrl?: string;
7
7
  };
8
8
  correctAnswer?: {
9
9
  answer: string;
@@ -13,7 +13,9 @@ export interface MatchWordToPictureGroupItem {
13
13
  }
14
14
  export interface MatchWordToPictureGroupContent {
15
15
  meta?: {
16
+ title?: string;
16
17
  instruction?: string;
18
+ imageUrl?: string;
17
19
  wordBank?: string[];
18
20
  };
19
21
  items: MatchWordToPictureGroupItem[];
@@ -1,9 +1,11 @@
1
1
  export interface MatchingWithLinesRightItem {
2
2
  id: string;
3
- text: string;
3
+ text?: string;
4
+ imageUrl?: string;
4
5
  }
5
6
  export interface MatchingWithLinesGroupItemContent {
6
- imageUrl: string;
7
+ imageUrl?: string;
8
+ text?: string;
7
9
  }
8
10
  export interface MatchingWithLinesGroupItem {
9
11
  isExample?: boolean;
@@ -18,6 +20,9 @@ export interface MatchingWithLinesGroupContent {
18
20
  meta?: {
19
21
  instruction?: string;
20
22
  title?: string;
23
+ audioUrl?: string;
24
+ passage?: string;
25
+ imageUrl?: string;
21
26
  };
22
27
  rightItems: MatchingWithLinesRightItem[];
23
28
  items: MatchingWithLinesGroupItem[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tinyweb_dev/oe-exam-sdk",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "Reusable OceanEdu question components.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",