@tinyweb_dev/oe-exam-sdk 0.2.5 → 0.2.7
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/components/exams/take/ExamTakingPageContainer.js +6 -0
- package/dist/components/exams/take/components/QuestionRenderer.js +10 -2
- package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.d.ts +22 -0
- package/dist/components/exams/take/components/question-renderers/MoversFillInBlankGroupRenderer.js +60 -0
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.d.ts +2 -2
- package/dist/components/exams/take/components/question-renderers/MoversListenAndWriteRenderer.js +58 -14
- package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.d.ts +13 -0
- package/dist/components/exams/take/components/question-renderers/MoversMatchingWithLinesGroupRenderer.js +31 -0
- package/dist/components/exams/take/components/question-renderers/index.d.ts +2 -0
- package/dist/components/exams/take/components/question-renderers/index.js +2 -0
- package/dist/components/exams/take/types.d.ts +12 -5
- package/dist/components/exams/take/utils/answer-transformers.js +17 -1
- package/dist/components/exams/take/utils/question-transformers.d.ts +45 -1
- package/dist/components/exams/take/utils/question-transformers.js +109 -53
- package/dist/components/questions/_shared/config/question-types.config.js +12 -0
- package/dist/components/questions/_shared/types/fill-in-blank-group.type.d.ts +50 -0
- package/dist/components/questions/_shared/types/fill-in-blank-group.type.js +2 -0
- package/dist/components/questions/_shared/types/fill-in-blank.type.d.ts +16 -1
- package/dist/components/questions/_shared/types/index.d.ts +2 -0
- package/dist/components/questions/_shared/types/index.js +2 -0
- package/dist/components/questions/_shared/types/matching-with-lines-group.type.d.ts +40 -0
- package/dist/components/questions/_shared/types/matching-with-lines-group.type.js +1 -0
- package/dist/components/questions/components/QuestionSearchDropdown.js +4 -0
- package/dist/components/questions/components/QuestionTypeSelector.js +2 -0
- package/dist/components/questions/creator/question-type-registry.js +4 -0
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.d.ts +1 -1
- package/dist/components/questions/types/fill-in-blank/FillInBlankClient.js +37 -60
- package/dist/components/questions/types/fill-in-blank/FillInBlankCreator.js +122 -149
- package/dist/components/questions/types/fill-in-blank/answer-utils.d.ts +34 -0
- package/dist/components/questions/types/fill-in-blank/answer-utils.js +58 -0
- package/dist/components/questions/types/fill-in-blank/register.js +19 -6
- package/dist/components/questions/types/fill-in-blank/transform.js +23 -26
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.d.ts +2 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupClient.js +64 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.d.ts +2 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupCreator.js +158 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.d.ts +7 -0
- package/dist/components/questions/types/fill-in-blank-group/FillInBlankGroupMediaField.js +30 -0
- package/dist/components/questions/types/fill-in-blank-group/blank-utils.d.ts +20 -0
- package/dist/components/questions/types/fill-in-blank-group/blank-utils.js +99 -0
- package/dist/components/questions/types/fill-in-blank-group/index.d.ts +5 -0
- package/dist/components/questions/types/fill-in-blank-group/index.js +5 -0
- package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.d.ts +4 -0
- package/dist/components/questions/types/fill-in-blank-group/map-fill-in-blank-group-data.js +102 -0
- package/dist/components/questions/types/fill-in-blank-group/register.d.ts +3 -0
- package/dist/components/questions/types/fill-in-blank-group/register.js +37 -0
- package/dist/components/questions/types/fill-in-blank-group/transform.d.ts +2 -0
- package/dist/components/questions/types/fill-in-blank-group/transform.js +68 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.d.ts +20 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesBoard.js +109 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.d.ts +2 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupClient.js +30 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.d.ts +2 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesGroupCreator.js +180 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.d.ts +6 -0
- package/dist/components/questions/types/matching-with-lines-group/MatchingWithLinesImageField.js +26 -0
- package/dist/components/questions/types/matching-with-lines-group/index.d.ts +4 -0
- package/dist/components/questions/types/matching-with-lines-group/index.js +4 -0
- package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.d.ts +8 -0
- package/dist/components/questions/types/matching-with-lines-group/map-matching-with-lines-group-data.js +104 -0
- package/dist/components/questions/types/matching-with-lines-group/register.d.ts +3 -0
- package/dist/components/questions/types/matching-with-lines-group/register.js +37 -0
- package/dist/components/questions/types/matching-with-lines-group/transform.d.ts +2 -0
- package/dist/components/questions/types/matching-with-lines-group/transform.js +44 -0
- package/dist/components/questions/viewer/FillInBlankGroupViewer.d.ts +10 -0
- package/dist/components/questions/viewer/FillInBlankGroupViewer.js +14 -0
- package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.d.ts +7 -0
- package/dist/components/questions/viewer/MatchingWithLinesGroupViewer.js +48 -0
- package/dist/components/questions/viewer/QuestionViewer.js +6 -0
- package/dist/components/questions/viewer/index.d.ts +2 -0
- package/dist/components/questions/viewer/index.js +2 -0
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.d.ts +2 -3
- package/dist/components/results/renderers/ReviewListenAndWriteRenderer.js +17 -8
- package/dist/components/results/renderers/review-question-body-dedicated.js +2 -2
- package/dist/components/results/renderers/review-question-body-movers.js +37 -2
- package/dist/components/results/review-data.js +37 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/shared/constants/question-skills.js +4 -0
- package/dist/shared/lib/utils/fill-in-blank.d.ts +46 -0
- package/dist/shared/lib/utils/fill-in-blank.js +96 -0
- package/dist/shared/lib/utils/question-reverse-transform.js +150 -3
- package/dist/shared/lib/utils/question-transform.js +4 -0
- package/dist/shared/types/common.types.d.ts +1 -1
- package/dist/shared/types/questions/fill-in-blank-group.d.ts +45 -0
- package/dist/shared/types/questions/fill-in-blank-group.js +1 -0
- package/dist/shared/types/questions/index.d.ts +2 -0
- package/dist/shared/types/questions/index.js +4 -0
- package/dist/shared/types/questions/matching-with-lines-group.d.ts +39 -0
- package/dist/shared/types/questions/matching-with-lines-group.js +1 -0
- package/package.json +1 -1
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { FillInBlankCreator } from './FillInBlankCreator';
|
|
2
|
+
import { normalizeFillInBlankAnswers } from './answer-utils';
|
|
2
3
|
export const fillInBlankRegistration = {
|
|
3
4
|
component: FillInBlankCreator,
|
|
4
5
|
transformInitialData: (initialData) => {
|
|
@@ -18,14 +19,16 @@ export const fillInBlankRegistration = {
|
|
|
18
19
|
const groupTitle = groupPayload?.title || answer?.title || '';
|
|
19
20
|
const groupContent = groupPayload?.content || groupPayload?.passage || answer?.groupContent || '';
|
|
20
21
|
const groupNumber = questionGroup?.groupNumber || answer?.groupNumber || 1;
|
|
22
|
+
const audioUrl = answer?.audioUrl || content?.audioUrl || '';
|
|
21
23
|
// NEW FORMAT: question with {0}, {1}, {2}... placeholders and answers array
|
|
22
24
|
if (answer?.question && Array.isArray(answer?.answers)) {
|
|
23
25
|
creatorData = {
|
|
24
26
|
question: answer.question,
|
|
25
|
-
answers: answer.answers,
|
|
27
|
+
answers: normalizeFillInBlankAnswers(answer.answers),
|
|
26
28
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
27
29
|
imageUrl: questionImageUrl,
|
|
28
30
|
groupImageUrl,
|
|
31
|
+
audioUrl,
|
|
29
32
|
showHints: answer?.showHints || false,
|
|
30
33
|
hints: answer?.hints || [],
|
|
31
34
|
explanation,
|
|
@@ -40,10 +43,11 @@ export const fillInBlankRegistration = {
|
|
|
40
43
|
else if (content?.question && Array.isArray(answer?.answers)) {
|
|
41
44
|
creatorData = {
|
|
42
45
|
question: content.question,
|
|
43
|
-
answers: answer.answers,
|
|
46
|
+
answers: normalizeFillInBlankAnswers(answer.answers),
|
|
44
47
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
45
48
|
imageUrl: questionImageUrl,
|
|
46
49
|
groupImageUrl,
|
|
50
|
+
audioUrl,
|
|
47
51
|
showHints: content?.showHints || false,
|
|
48
52
|
hints: content?.hints || [],
|
|
49
53
|
explanation,
|
|
@@ -60,7 +64,7 @@ export const fillInBlankRegistration = {
|
|
|
60
64
|
const firstBlank = answer.blanks[0];
|
|
61
65
|
creatorData = {
|
|
62
66
|
question: firstBlank?.text || '',
|
|
63
|
-
answers: answer.blanks.map((b) => b.correctAnswer
|
|
67
|
+
answers: normalizeFillInBlankAnswers(answer.blanks.map((b) => b.correctAnswers ?? b.correctAnswer ?? '')),
|
|
64
68
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
65
69
|
showHints: answer?.showHints || false,
|
|
66
70
|
hints: answer?.hints || [],
|
|
@@ -69,6 +73,7 @@ export const fillInBlankRegistration = {
|
|
|
69
73
|
blanks: answer.blanks,
|
|
70
74
|
imageUrl: questionImageUrl,
|
|
71
75
|
groupImageUrl,
|
|
76
|
+
audioUrl,
|
|
72
77
|
title: groupTitle,
|
|
73
78
|
groupContent,
|
|
74
79
|
groupNumber,
|
|
@@ -79,7 +84,7 @@ export const fillInBlankRegistration = {
|
|
|
79
84
|
// LEGACY Format 1: Single blank
|
|
80
85
|
creatorData = {
|
|
81
86
|
question: content.text || '',
|
|
82
|
-
answers: [answer?.correctAnswer || ''],
|
|
87
|
+
answers: normalizeFillInBlankAnswers([answer?.correctAnswer || '']),
|
|
83
88
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
84
89
|
showHints: content?.showHints || false,
|
|
85
90
|
hints: content?.hints || [],
|
|
@@ -87,6 +92,7 @@ export const fillInBlankRegistration = {
|
|
|
87
92
|
isExample: initialData?.isExample || answer?.isExample || false,
|
|
88
93
|
imageUrl: questionImageUrl,
|
|
89
94
|
groupImageUrl,
|
|
95
|
+
audioUrl,
|
|
90
96
|
title: groupTitle,
|
|
91
97
|
groupContent,
|
|
92
98
|
groupNumber,
|
|
@@ -98,12 +104,17 @@ export const fillInBlankRegistration = {
|
|
|
98
104
|
const answersMap = answer?.answers || {};
|
|
99
105
|
const mergedBlanks = content.blanks.map((blank) => ({
|
|
100
106
|
...blank,
|
|
101
|
-
correctAnswer: answersMap[blank.id]
|
|
107
|
+
correctAnswer: Array.isArray(answersMap[blank.id])
|
|
108
|
+
? (answersMap[blank.id][0] || blank.correctAnswer || '')
|
|
109
|
+
: (answersMap[blank.id] || blank.correctAnswer || ''),
|
|
110
|
+
correctAnswers: Array.isArray(answersMap[blank.id])
|
|
111
|
+
? answersMap[blank.id]
|
|
112
|
+
: undefined,
|
|
102
113
|
}));
|
|
103
114
|
const firstBlank = mergedBlanks[0];
|
|
104
115
|
creatorData = {
|
|
105
116
|
question: firstBlank?.text || '',
|
|
106
|
-
answers: mergedBlanks.map((b) => b.correctAnswer
|
|
117
|
+
answers: normalizeFillInBlankAnswers(mergedBlanks.map((b) => b.correctAnswers ?? b.correctAnswer ?? '')),
|
|
107
118
|
caseSensitive: answer?.caseSensitive ?? false,
|
|
108
119
|
showHints: content?.showHints || false,
|
|
109
120
|
hints: content?.hints || [],
|
|
@@ -112,6 +123,7 @@ export const fillInBlankRegistration = {
|
|
|
112
123
|
blanks: mergedBlanks,
|
|
113
124
|
imageUrl: questionImageUrl,
|
|
114
125
|
groupImageUrl,
|
|
126
|
+
audioUrl,
|
|
115
127
|
title: groupTitle,
|
|
116
128
|
groupContent,
|
|
117
129
|
groupNumber,
|
|
@@ -126,6 +138,7 @@ export const fillInBlankRegistration = {
|
|
|
126
138
|
groups: ctx.groups,
|
|
127
139
|
questionIndexInPart: ctx.questionIndexInPart,
|
|
128
140
|
partId: ctx.partId,
|
|
141
|
+
questionConfig: ctx.questionConfig,
|
|
129
142
|
}),
|
|
130
143
|
wrapOnSave: (data, ctx) => ({
|
|
131
144
|
type: ctx.questionType,
|
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
if (!question)
|
|
3
|
-
return [];
|
|
4
|
-
const regex = /\{(\d+)\}/g;
|
|
5
|
-
const indices = [];
|
|
6
|
-
let match;
|
|
7
|
-
while ((match = regex.exec(question)) !== null) {
|
|
8
|
-
const index = parseInt(match[1], 10);
|
|
9
|
-
if (!indices.includes(index)) {
|
|
10
|
-
indices.push(index);
|
|
11
|
-
}
|
|
12
|
-
}
|
|
13
|
-
return indices.sort((a, b) => a - b);
|
|
14
|
-
}
|
|
15
|
-
function getMaxPlaceholderIndex(question) {
|
|
16
|
-
const indices = extractPlaceholderIndices(question);
|
|
17
|
-
return indices.length > 0 ? Math.max(...indices) : -1;
|
|
18
|
-
}
|
|
1
|
+
import { compactFillInBlankAnswers, normalizeFillInBlankAnswers, getMaxPlaceholderIndex } from './answer-utils';
|
|
19
2
|
export const transformFillInBlank = (question) => {
|
|
20
3
|
const answerData = question.answer;
|
|
21
4
|
// Helper: build basicGroupData if any group field is present.
|
|
@@ -32,12 +15,8 @@ export const transformFillInBlank = (question) => {
|
|
|
32
15
|
: undefined;
|
|
33
16
|
const groupNumber = answerData?.groupNumber || 1;
|
|
34
17
|
// NEW FORMAT: question with {0}, {1}, {2}... placeholders and answers array
|
|
18
|
+
// answers: string[][] (canonical) or legacy string[]
|
|
35
19
|
if (answerData?.question && Array.isArray(answerData?.answers)) {
|
|
36
|
-
const maxIndex = getMaxPlaceholderIndex(answerData.question || '');
|
|
37
|
-
const requiredLength = maxIndex >= 0 ? maxIndex + 1 : 1;
|
|
38
|
-
const validAnswers = answerData.answers.length > requiredLength
|
|
39
|
-
? answerData.answers.slice(0, requiredLength)
|
|
40
|
-
: answerData.answers;
|
|
41
20
|
const apiContent = {
|
|
42
21
|
question: answerData.question,
|
|
43
22
|
showHints: answerData.showHints || false,
|
|
@@ -47,10 +26,17 @@ export const transformFillInBlank = (question) => {
|
|
|
47
26
|
if (answerData.imageUrl) {
|
|
48
27
|
apiContent.imageUrl = answerData.imageUrl;
|
|
49
28
|
}
|
|
29
|
+
// Per-question audio (when questionConfig includes "audio")
|
|
30
|
+
if (answerData.audioUrl) {
|
|
31
|
+
apiContent.audioUrl = answerData.audioUrl;
|
|
32
|
+
}
|
|
33
|
+
const maxIndex = getMaxPlaceholderIndex(answerData.question || '');
|
|
34
|
+
const requiredLength = maxIndex >= 0 ? maxIndex + 1 : undefined;
|
|
35
|
+
const normalizedAnswers = compactFillInBlankAnswers(normalizeFillInBlankAnswers(answerData.answers, requiredLength), requiredLength);
|
|
50
36
|
return {
|
|
51
37
|
apiContent,
|
|
52
38
|
apiCorrectAnswer: {
|
|
53
|
-
answers:
|
|
39
|
+
answers: normalizedAnswers,
|
|
54
40
|
caseSensitive: answerData.caseSensitive || false,
|
|
55
41
|
},
|
|
56
42
|
isExample: answerData.isExample || false,
|
|
@@ -66,9 +52,17 @@ export const transformFillInBlank = (question) => {
|
|
|
66
52
|
id: blank.id,
|
|
67
53
|
text: blank.text,
|
|
68
54
|
}));
|
|
55
|
+
// Prefer correctAnswers[]; fall back to single correctAnswer string
|
|
69
56
|
const answers = (answerData.blanks || []).reduce((acc, blank) => {
|
|
70
|
-
if (blank.id
|
|
71
|
-
acc
|
|
57
|
+
if (!blank.id)
|
|
58
|
+
return acc;
|
|
59
|
+
const alts = blank.correctAnswers ?? blank.correctAnswer;
|
|
60
|
+
if (alts == null)
|
|
61
|
+
return acc;
|
|
62
|
+
const normalized = normalizeFillInBlankAnswers(Array.isArray(alts) ? [alts] : [alts])[0];
|
|
63
|
+
const cleaned = normalized.map((a) => a.trim()).filter(Boolean);
|
|
64
|
+
if (cleaned.length > 0) {
|
|
65
|
+
acc[blank.id] = cleaned;
|
|
72
66
|
}
|
|
73
67
|
return acc;
|
|
74
68
|
}, {});
|
|
@@ -76,6 +70,9 @@ export const transformFillInBlank = (question) => {
|
|
|
76
70
|
if (answerData.imageUrl) {
|
|
77
71
|
legacyContent.imageUrl = answerData.imageUrl;
|
|
78
72
|
}
|
|
73
|
+
if (answerData.audioUrl) {
|
|
74
|
+
legacyContent.audioUrl = answerData.audioUrl;
|
|
75
|
+
}
|
|
79
76
|
return {
|
|
80
77
|
apiContent: legacyContent,
|
|
81
78
|
apiCorrectAnswer: {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Lightbulb, Star, Volume2 } from 'lucide-react';
|
|
4
|
+
import { cn } from '../../../../shared/lib/utils';
|
|
5
|
+
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
6
|
+
import { blankSlotCount, extractPlaceholderIndices, isBlankMatch, joinSlashAnswers, padStudentAnswers, splitStem, } from './blank-utils';
|
|
7
|
+
function MediaPreview({ src, kind, alt }) {
|
|
8
|
+
const { previewUrl, isLoading } = usePresignedFileUrl(src);
|
|
9
|
+
if (isLoading) {
|
|
10
|
+
return _jsx("div", { className: "h-16 w-16 animate-pulse rounded-md bg-slate-100" });
|
|
11
|
+
}
|
|
12
|
+
const url = previewUrl || src;
|
|
13
|
+
if (kind === 'audio') {
|
|
14
|
+
return _jsx("audio", { controls: true, src: url, className: "h-8 max-w-full" });
|
|
15
|
+
}
|
|
16
|
+
return (_jsx("img", { src: url, alt: alt, className: "max-h-28 w-auto rounded-md object-contain", onError: (event) => {
|
|
17
|
+
const target = event.target;
|
|
18
|
+
target.onerror = null;
|
|
19
|
+
target.src = '/images/placeholder-image.svg';
|
|
20
|
+
} }));
|
|
21
|
+
}
|
|
22
|
+
export function FillInBlankGroupClient({ questionData, isReviewMode = false, userAnswers = [], onAnswerChange, }) {
|
|
23
|
+
const items = questionData.items || [];
|
|
24
|
+
const groupHints = questionData.showHints ? (questionData.hints || []) : [];
|
|
25
|
+
const handleBlankChange = (itemIndex, blankIndex, value) => {
|
|
26
|
+
const item = items[itemIndex];
|
|
27
|
+
if (isReviewMode || item?.isExample || !onAnswerChange) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
const next = items.map((row, index) => ({
|
|
31
|
+
answers: padStudentAnswers(userAnswers[index], blankSlotCount(row.question || '{0}')),
|
|
32
|
+
}));
|
|
33
|
+
const current = [...next[itemIndex].answers];
|
|
34
|
+
current[blankIndex] = value;
|
|
35
|
+
next[itemIndex] = { answers: current };
|
|
36
|
+
onAnswerChange(next);
|
|
37
|
+
};
|
|
38
|
+
return (_jsxs("div", { className: "space-y-4", children: [questionData.instruction && (_jsx("div", { className: "rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm font-medium text-indigo-800", children: questionData.instruction })), questionData.audioUrl && (_jsxs("div", { className: "flex items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2", children: [_jsx(Volume2, { className: "h-4 w-4 text-slate-500" }), _jsx(MediaPreview, { src: questionData.audioUrl, kind: "audio", alt: "Group audio" })] })), questionData.imageUrl && (_jsx("div", { className: "flex justify-center rounded-lg border border-slate-100 bg-slate-50 p-3", children: _jsx(MediaPreview, { src: questionData.imageUrl, kind: "image", alt: "Group image" }) })), groupHints.length > 0 && (_jsx("div", { className: "flex flex-wrap gap-2", children: groupHints.map((hint) => (_jsx("span", { className: "rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-semibold text-blue-700", children: hint }, hint))) })), _jsx("div", { className: "grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3", children: items.map((item, itemIndex) => {
|
|
39
|
+
const blankCount = blankSlotCount(item.question || '{0}');
|
|
40
|
+
const placeholderIndices = extractPlaceholderIndices(item.question || '{0}');
|
|
41
|
+
const userValue = padStudentAnswers(userAnswers[itemIndex], blankCount);
|
|
42
|
+
const stem = splitStem(item.question || '{0}');
|
|
43
|
+
const showItemAudio = Boolean(item.audioUrl) && !questionData.audioUrl;
|
|
44
|
+
return (_jsxs("div", { className: "overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm", children: [_jsxs("div", { className: "flex items-center justify-between border-b border-gray-100 bg-gray-50 px-3 py-2", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("span", { className: "flex h-6 w-6 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white", children: item.questionNumber || itemIndex + 1 }), item.isExample && (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-700", children: [_jsx(Star, { className: "h-3 w-3" }), "Example"] }))] }), _jsxs("span", { className: "text-xs text-gray-500", children: [item.points || 0, " \u0111i\u1EC3m"] })] }), _jsxs("div", { className: "flex items-center gap-3 p-3", children: [item.imageUrl && (_jsx(MediaPreview, { src: item.imageUrl, kind: "image", alt: `Item ${item.questionNumber}` })), _jsxs("div", { className: "min-w-0 flex-1 space-y-2 text-sm leading-relaxed text-slate-800", children: [showItemAudio && item.audioUrl && (_jsx(MediaPreview, { src: item.audioUrl, kind: "audio", alt: `Item ${item.questionNumber} audio` })), stem.map((part, partIndex) => {
|
|
45
|
+
if (part.type === 'text') {
|
|
46
|
+
return _jsx("span", { children: part.value }, partIndex);
|
|
47
|
+
}
|
|
48
|
+
const alts = item.answers[part.index] || [];
|
|
49
|
+
const student = userValue[part.index] || '';
|
|
50
|
+
const canEdit = !isReviewMode && !item.isExample && Boolean(onAnswerChange);
|
|
51
|
+
const correct = isReviewMode && isBlankMatch(student, alts);
|
|
52
|
+
const wrong = isReviewMode && student.trim().length > 0 && !correct;
|
|
53
|
+
return (_jsx("input", { type: "text", disabled: !canEdit, value: item.isExample ? (alts[0] || '') : student, onChange: (event) => handleBlankChange(itemIndex, part.index, event.target.value), placeholder: item.isExample ? '' : '...', className: cn('mx-1 inline-block w-16 rounded-md border-2 px-1 py-0.5 text-center text-sm font-semibold outline-none', item.isExample
|
|
54
|
+
? 'border-amber-300 bg-amber-50 text-amber-800'
|
|
55
|
+
: correct
|
|
56
|
+
? 'border-emerald-400 bg-emerald-50 text-emerald-800'
|
|
57
|
+
: wrong
|
|
58
|
+
? 'border-rose-400 bg-rose-50 text-rose-800'
|
|
59
|
+
: canEdit
|
|
60
|
+
? 'border-blue-300 bg-white text-slate-800 focus:border-blue-500'
|
|
61
|
+
: 'border-blue-200 bg-blue-50 text-blue-900') }, partIndex));
|
|
62
|
+
})] })] }), isReviewMode && !item.isExample && (_jsxs("div", { className: "border-t border-emerald-100 bg-emerald-50 px-3 py-2 text-xs text-emerald-800", children: ["\u0110\u00E1p \u00E1n: ", placeholderIndices.map((index) => joinSlashAnswers(item.answers[index]) || '(trống)').join(' | ')] }))] }, `${item.questionNumber}-${itemIndex}`));
|
|
63
|
+
}) }), isReviewMode && questionData.explanation && (_jsxs("div", { className: "flex items-start gap-2 rounded-xl border border-amber-100 bg-amber-50 px-4 py-3 text-sm text-amber-800", children: [_jsx(Lightbulb, { className: "mt-0.5 h-4 w-4 flex-shrink-0" }), questionData.explanation] }))] }));
|
|
64
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import type { FillInBlankGroupCreatorProps } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
export declare function FillInBlankGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }: FillInBlankGroupCreatorProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { Button } from '../../../../components/ui/button';
|
|
5
|
+
import { Label } from '../../../../components/ui/label';
|
|
6
|
+
import { Input } from '../../../../components/ui/input';
|
|
7
|
+
import { Textarea } from '../../../../components/ui/textarea';
|
|
8
|
+
import { PointsInput } from '../../../../components/ui/points-input';
|
|
9
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../../../../components/ui/card';
|
|
10
|
+
import { Switch } from '../../../../components/ui/switch';
|
|
11
|
+
import { useDebouncedCallback } from '../../../../shared/lib/hooks';
|
|
12
|
+
import { Eye, Pencil, Plus, Star, Trash2 } from 'lucide-react';
|
|
13
|
+
import { FillInBlankGroupClient } from './FillInBlankGroupClient';
|
|
14
|
+
import { FillInBlankGroupMediaField } from './FillInBlankGroupMediaField';
|
|
15
|
+
import { createEmptyFillInBlankGroupItem } from './map-fill-in-blank-group-data';
|
|
16
|
+
import { blankSlotCount, extractPlaceholderIndices, joinSlashAnswers, normalizeAnswersMatrix, parseSlashAnswers, } from './blank-utils';
|
|
17
|
+
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
|
|
18
|
+
function parsePointsValue(raw, allowZero = false) {
|
|
19
|
+
if (raw === '')
|
|
20
|
+
return allowZero ? 0 : 1;
|
|
21
|
+
const parsed = parseInt(raw.replace(/[^0-9]/g, ''), 10);
|
|
22
|
+
if (Number.isNaN(parsed))
|
|
23
|
+
return allowZero ? 0 : 1;
|
|
24
|
+
const min = allowZero ? 0 : 1;
|
|
25
|
+
return Math.max(min, parsed);
|
|
26
|
+
}
|
|
27
|
+
function syncItemAnswers(item) {
|
|
28
|
+
const indices = extractPlaceholderIndices(item.question || '');
|
|
29
|
+
const requiredLength = blankSlotCount(item.question || '');
|
|
30
|
+
return {
|
|
31
|
+
...item,
|
|
32
|
+
answers: normalizeAnswersMatrix(item.answers, requiredLength).map((alts, index) => {
|
|
33
|
+
if (!indices.includes(index))
|
|
34
|
+
return [];
|
|
35
|
+
return alts.length > 0 ? alts : [''];
|
|
36
|
+
}),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function normalizeItems(rawItems) {
|
|
40
|
+
if (!Array.isArray(rawItems) || rawItems.length === 0) {
|
|
41
|
+
return [createEmptyFillInBlankGroupItem(1)];
|
|
42
|
+
}
|
|
43
|
+
return rawItems.map((item, index) => syncItemAnswers({
|
|
44
|
+
...createEmptyFillInBlankGroupItem(index + 1),
|
|
45
|
+
...item,
|
|
46
|
+
question: item.question || '{0}',
|
|
47
|
+
isExample: item.isExample === true,
|
|
48
|
+
points: item.isExample ? 0 : (typeof item.points === 'number' ? item.points : 1),
|
|
49
|
+
questionNumber: item.questionNumber || index + 1,
|
|
50
|
+
}));
|
|
51
|
+
}
|
|
52
|
+
function sumPoints(items) {
|
|
53
|
+
return items.reduce((total, item) => (item.isExample ? total : total + (typeof item.points === 'number' ? item.points : 1)), 0);
|
|
54
|
+
}
|
|
55
|
+
export function FillInBlankGroupCreator({ initialData, onChange, externalErrors, onUnsavedChangesChange, validationRef, }) {
|
|
56
|
+
const [instruction, setInstruction] = useState(initialData?.instruction || FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION);
|
|
57
|
+
const [audioUrl, setAudioUrl] = useState(initialData?.audioUrl || '');
|
|
58
|
+
const [imageUrl, setImageUrl] = useState(initialData?.imageUrl || '');
|
|
59
|
+
const [showHints, setShowHints] = useState(initialData?.showHints === true);
|
|
60
|
+
const [hintsText, setHintsText] = useState((initialData?.hints || []).join(' / '));
|
|
61
|
+
const [items, setItems] = useState(() => normalizeItems(initialData?.items));
|
|
62
|
+
const [explanation, setExplanation] = useState(initialData?.explanation || '');
|
|
63
|
+
const [isClientMode, setIsClientMode] = useState(false);
|
|
64
|
+
const [errors, setErrors] = useState([]);
|
|
65
|
+
const onChangeRef = useRef(onChange);
|
|
66
|
+
const previousPayloadRef = useRef('');
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
onChangeRef.current = onChange;
|
|
69
|
+
}, [onChange]);
|
|
70
|
+
const parseHints = (value) => parseSlashAnswers(value);
|
|
71
|
+
const buildPayload = (nextInstruction = instruction, nextItems = items, nextExplanation = explanation, nextAudioUrl = audioUrl, nextImageUrl = imageUrl, nextShowHints = showHints, nextHintsText = hintsText) => ({
|
|
72
|
+
instruction: nextInstruction,
|
|
73
|
+
audioUrl: nextAudioUrl,
|
|
74
|
+
imageUrl: nextImageUrl,
|
|
75
|
+
showHints: nextShowHints,
|
|
76
|
+
hints: parseHints(nextHintsText),
|
|
77
|
+
items: nextItems,
|
|
78
|
+
explanation: nextExplanation,
|
|
79
|
+
points: sumPoints(nextItems),
|
|
80
|
+
});
|
|
81
|
+
const debouncedOnChange = useDebouncedCallback((payload) => {
|
|
82
|
+
onChangeRef.current?.(payload);
|
|
83
|
+
}, 300);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const payload = buildPayload();
|
|
86
|
+
const serialized = JSON.stringify(payload);
|
|
87
|
+
if (serialized !== previousPayloadRef.current) {
|
|
88
|
+
previousPayloadRef.current = serialized;
|
|
89
|
+
debouncedOnChange(payload);
|
|
90
|
+
onUnsavedChangesChange?.(true);
|
|
91
|
+
}
|
|
92
|
+
}, [instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation, debouncedOnChange, onUnsavedChangesChange]);
|
|
93
|
+
const validate = () => {
|
|
94
|
+
const nextErrors = [];
|
|
95
|
+
if (!instruction.trim()) {
|
|
96
|
+
nextErrors.push('Hướng dẫn (Instruction) không được để trống.');
|
|
97
|
+
}
|
|
98
|
+
if (items.length === 0) {
|
|
99
|
+
nextErrors.push('Cần ít nhất một câu hỏi trong nhóm.');
|
|
100
|
+
}
|
|
101
|
+
items.forEach((item, index) => {
|
|
102
|
+
if (!item.question.trim()) {
|
|
103
|
+
nextErrors.push(`Câu ${index + 1}: Stem không được để trống.`);
|
|
104
|
+
}
|
|
105
|
+
else if (extractPlaceholderIndices(item.question).length === 0) {
|
|
106
|
+
nextErrors.push(`Câu ${index + 1}: Stem cần có chỗ trống {0}, {1}...`);
|
|
107
|
+
}
|
|
108
|
+
item.answers.forEach((alts, blankIndex) => {
|
|
109
|
+
if (!extractPlaceholderIndices(item.question).includes(blankIndex))
|
|
110
|
+
return;
|
|
111
|
+
if (!alts.some((alt) => alt.trim())) {
|
|
112
|
+
nextErrors.push(`Câu ${index + 1}: Blank {${blankIndex}} cần ít nhất một đáp án.`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
setErrors(nextErrors);
|
|
117
|
+
return nextErrors.length === 0;
|
|
118
|
+
};
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (validationRef) {
|
|
121
|
+
validationRef.current = {
|
|
122
|
+
triggerValidation: async () => validate(),
|
|
123
|
+
getFormData: () => buildPayload(),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
}, [validationRef, instruction, audioUrl, imageUrl, showHints, hintsText, items, explanation]);
|
|
127
|
+
const updateItem = (index, patch) => {
|
|
128
|
+
setItems((prev) => {
|
|
129
|
+
const next = [...prev];
|
|
130
|
+
const current = next[index];
|
|
131
|
+
if (!current)
|
|
132
|
+
return prev;
|
|
133
|
+
next[index] = syncItemAnswers({ ...current, ...patch });
|
|
134
|
+
return next;
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
const addItem = () => {
|
|
138
|
+
setItems((prev) => [...prev, createEmptyFillInBlankGroupItem(prev.length + 1)]);
|
|
139
|
+
};
|
|
140
|
+
const removeItem = (index) => {
|
|
141
|
+
setItems((prev) => {
|
|
142
|
+
if (prev.length <= 1)
|
|
143
|
+
return prev;
|
|
144
|
+
return prev
|
|
145
|
+
.filter((_, i) => i !== index)
|
|
146
|
+
.map((item, i) => ({ ...item, questionNumber: i + 1 }));
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const currentPayload = buildPayload();
|
|
150
|
+
const displayErrors = externalErrors && externalErrors.length > 0 ? externalErrors : errors;
|
|
151
|
+
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 \u0110i\u1EC1n ch\u1ED7 tr\u1ED1ng (Nh\u00F3m)" }), _jsxs("p", { className: "text-sm text-gray-500", children: ["M\u1ED7i d\u00F2ng l\u00E0 1 stem c\u00F3 ", '{0}', ". C\u00F3 th\u1EC3 th\u00EAm audio/\u1EA3nh chung ho\u1EB7c t\u1EEBng item."] })] }), _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(FillInBlankGroupClient, { questionData: currentPayload }) })] })) : (_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 v\u00E0 media chung" }) }), _jsxs(CardContent, { className: "space-y-4", children: [_jsxs("div", { children: [_jsx(Label, { htmlFor: "instruction", children: "H\u01B0\u1EDBng d\u1EABn b\u00E0i l\u00E0m" }), _jsx(Input, { id: "instruction", value: instruction, onChange: (e) => setInstruction(e.target.value), placeholder: FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION, className: "mt-1" })] }), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: "fib-group-audio", label: "Audio chung (optional)", accept: "audio/*", value: audioUrl, onChange: setAudioUrl }), _jsx(FillInBlankGroupMediaField, { id: "fib-group-image", label: "\u1EA2nh chung (optional)", accept: "image/*", value: imageUrl, onChange: setImageUrl })] }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: "show-hints", checked: showHints, onCheckedChange: setShowHints }), _jsx(Label, { htmlFor: "show-hints", children: "Hi\u1EC7n h\u1ED9p t\u1EEB g\u1EE3i \u00FD chung" })] }), showHints && (_jsx(Input, { value: hintsText, onChange: (e) => setHintsText(e.target.value), placeholder: "am / is / are" }))] })] }), _jsxs(Card, { children: [_jsxs(CardHeader, { className: "flex flex-row items-center justify-between", children: [_jsxs(CardTitle, { className: "text-base font-semibold", children: ["Danh s\u00E1ch c\u00E2u (", items.length, ")"] }), _jsxs(Button, { type: "button", variant: "outline", size: "sm", onClick: addItem, className: "gap-2", children: [_jsx(Plus, { className: "h-4 w-4" }), "Th\u00EAm c\u00E2u"] })] }), _jsx(CardContent, { className: "space-y-4", children: items.map((item, index) => (_jsxs("div", { className: "space-y-3 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: "flex items-center gap-2 text-sm font-medium text-gray-700", children: [_jsx("span", { className: "flex h-7 w-7 items-center justify-center rounded-full bg-blue-100 text-xs font-semibold text-blue-700", children: item.questionNumber || index + 1 }), "C\u00E2u ", index + 1] }), _jsxs("div", { className: "flex items-center gap-4", children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Switch, { id: `example-${index}`, checked: item.isExample || false, onCheckedChange: (checked) => updateItem(index, { isExample: checked, points: checked ? 0 : 1 }) }), _jsxs(Label, { htmlFor: `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, {
|
|
152
|
+
points: parsePointsValue(event.target.value, true),
|
|
153
|
+
}) }) })), items.length > 1 && (_jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => removeItem(index), className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) }))] })] }), _jsxs("div", { children: [_jsxs(Label, { htmlFor: `question-${index}`, children: ["Stem (d\u00F9ng ", '{0}', ", ", '{1}', "...)"] }), _jsx(Textarea, { id: `question-${index}`, value: item.question, onChange: (e) => updateItem(index, { question: e.target.value }), placeholder: "{0}", className: "mt-1" })] }), extractPlaceholderIndices(item.question).map((blankIndex) => (_jsxs("div", { children: [_jsxs(Label, { htmlFor: `answer-${index}-${blankIndex}`, children: ["\u0110\u00E1p \u00E1n blank ", '{' + blankIndex + '}', " (c\u00E1ch nhau b\u1EDFi /)"] }), _jsx(Input, { id: `answer-${index}-${blankIndex}`, value: joinSlashAnswers(item.answers[blankIndex]), onChange: (e) => {
|
|
154
|
+
const next = [...item.answers];
|
|
155
|
+
next[blankIndex] = parseSlashAnswers(e.target.value);
|
|
156
|
+
updateItem(index, { answers: next });
|
|
157
|
+
}, placeholder: "is / 's", className: "mt-1" })] }, blankIndex))), _jsxs("div", { className: "grid gap-4 md:grid-cols-2", children: [_jsx(FillInBlankGroupMediaField, { id: `fib-item-image-${index}`, label: "\u1EA2nh item (optional)", accept: "image/*", value: item.imageUrl || '', onChange: (url) => updateItem(index, { imageUrl: url }) }), !audioUrl && (_jsx(FillInBlankGroupMediaField, { id: `fib-item-audio-${index}`, label: "Audio item (khi kh\u00F4ng c\u00F3 audio nh\u00F3m)", accept: "audio/*", value: item.audioUrl || '', onChange: (url) => updateItem(index, { audioUrl: url }) }))] })] }, 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 }) })] })] }))] }));
|
|
158
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { Button } from '../../../../components/ui/button';
|
|
5
|
+
import { FileUpload } from '../../../../components/ui/file-upload';
|
|
6
|
+
import { ImagePreview } from '../../../../components/ui/image-preview';
|
|
7
|
+
import { usePresignedFileUrl } from '../../../../shared/lib/hooks';
|
|
8
|
+
import { Trash2 } from 'lucide-react';
|
|
9
|
+
function resolveDisplayUrl(fileUrl, previewOverride, fetchedPreviewUrl) {
|
|
10
|
+
if (previewOverride)
|
|
11
|
+
return previewOverride;
|
|
12
|
+
if (fetchedPreviewUrl)
|
|
13
|
+
return fetchedPreviewUrl;
|
|
14
|
+
if (fileUrl.startsWith('http') || fileUrl.startsWith('blob:'))
|
|
15
|
+
return fileUrl;
|
|
16
|
+
return '';
|
|
17
|
+
}
|
|
18
|
+
export function FillInBlankGroupMediaField({ id, label, accept, value, onChange, }) {
|
|
19
|
+
const [previewOverride, setPreviewOverride] = useState('');
|
|
20
|
+
const { previewUrl: fetchedPreviewUrl } = usePresignedFileUrl(value);
|
|
21
|
+
const displayUrl = resolveDisplayUrl(value, previewOverride, fetchedPreviewUrl);
|
|
22
|
+
const isImage = accept === 'image/*';
|
|
23
|
+
return (_jsxs("div", { className: "space-y-2", children: [_jsx(FileUpload, { id: id, label: label, accept: accept, value: value, onChange: onChange, onPresignedUrlChange: setPreviewOverride, maxSize: accept === 'image/*' ? 5 : 20, placeholder: isImage ? 'Upload ảnh hoặc paste URL' : 'Upload audio hoặc paste URL', autoUpload: true, prefix: "questions" }), value && isImage && (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx(ImagePreview, { src: displayUrl || value, alt: label, className: "h-20 w-20 rounded-md object-contain" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
|
|
24
|
+
setPreviewOverride('');
|
|
25
|
+
onChange('');
|
|
26
|
+
}, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] })), value && !isImage && (_jsxs("div", { className: "flex items-center gap-2", children: [_jsx("audio", { controls: true, src: displayUrl || value, className: "h-8 max-w-full" }), _jsx(Button, { type: "button", variant: "ghost", size: "sm", onClick: () => {
|
|
27
|
+
setPreviewOverride('');
|
|
28
|
+
onChange('');
|
|
29
|
+
}, className: "text-red-500 hover:bg-red-50", children: _jsx(Trash2, { className: "h-4 w-4" }) })] }))] }));
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
|
|
3
|
+
export type FillBlankStemPart = {
|
|
4
|
+
type: 'text';
|
|
5
|
+
value: string;
|
|
6
|
+
} | {
|
|
7
|
+
type: 'blank';
|
|
8
|
+
index: number;
|
|
9
|
+
};
|
|
10
|
+
export declare function extractPlaceholderIndices(question: string): number[];
|
|
11
|
+
/** answers[j] aligns with placeholder {j}; length = max(N)+1 */
|
|
12
|
+
export declare function blankSlotCount(question: string): number;
|
|
13
|
+
export declare function normalizeBlankAlts(value: unknown): string[];
|
|
14
|
+
export declare function normalizeAnswersMatrix(raw: unknown, requiredLength?: number): string[][];
|
|
15
|
+
export declare function joinSlashAnswers(alts: string[] | undefined): string;
|
|
16
|
+
export declare function parseSlashAnswers(text: string): string[];
|
|
17
|
+
export declare function splitStem(question: string): FillBlankStemPart[];
|
|
18
|
+
export declare function normalizeStudentText(value: string): string;
|
|
19
|
+
export declare function isBlankMatch(studentValue: string | undefined, alts: string[] | undefined): boolean;
|
|
20
|
+
export declare function padStudentAnswers(raw: unknown, blankCount: number): string[];
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
export { FILL_IN_BLANK_GROUP_DEFAULT_INSTRUCTION };
|
|
3
|
+
export function extractPlaceholderIndices(question) {
|
|
4
|
+
if (!question)
|
|
5
|
+
return [];
|
|
6
|
+
const regex = /\{(\d+)\}/g;
|
|
7
|
+
const indices = [];
|
|
8
|
+
let match;
|
|
9
|
+
while ((match = regex.exec(question)) !== null) {
|
|
10
|
+
const index = Number.parseInt(match[1], 10);
|
|
11
|
+
if (!indices.includes(index)) {
|
|
12
|
+
indices.push(index);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return indices.sort((a, b) => a - b);
|
|
16
|
+
}
|
|
17
|
+
/** answers[j] aligns with placeholder {j}; length = max(N)+1 */
|
|
18
|
+
export function blankSlotCount(question) {
|
|
19
|
+
const indices = extractPlaceholderIndices(question);
|
|
20
|
+
if (indices.length === 0)
|
|
21
|
+
return 1;
|
|
22
|
+
return Math.max(...indices, 0) + 1;
|
|
23
|
+
}
|
|
24
|
+
export function normalizeBlankAlts(value) {
|
|
25
|
+
if (Array.isArray(value)) {
|
|
26
|
+
return value
|
|
27
|
+
.map((item) => (item == null ? '' : String(item).trim()))
|
|
28
|
+
.filter((item) => item.length > 0);
|
|
29
|
+
}
|
|
30
|
+
if (value == null)
|
|
31
|
+
return [];
|
|
32
|
+
const text = String(value).trim();
|
|
33
|
+
return text ? [text] : [];
|
|
34
|
+
}
|
|
35
|
+
export function normalizeAnswersMatrix(raw, requiredLength) {
|
|
36
|
+
let result = [];
|
|
37
|
+
if (Array.isArray(raw)) {
|
|
38
|
+
result = raw.map((item) => normalizeBlankAlts(item));
|
|
39
|
+
}
|
|
40
|
+
if (typeof requiredLength === 'number' && requiredLength > 0) {
|
|
41
|
+
if (result.length > requiredLength) {
|
|
42
|
+
result = result.slice(0, requiredLength);
|
|
43
|
+
}
|
|
44
|
+
while (result.length < requiredLength) {
|
|
45
|
+
result.push([]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
export function joinSlashAnswers(alts) {
|
|
51
|
+
return (alts || []).map((item) => item.trim()).filter(Boolean).join(' / ');
|
|
52
|
+
}
|
|
53
|
+
export function parseSlashAnswers(text) {
|
|
54
|
+
return text
|
|
55
|
+
.split('/')
|
|
56
|
+
.map((item) => item.trim())
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
export function splitStem(question) {
|
|
60
|
+
if (!question)
|
|
61
|
+
return [];
|
|
62
|
+
const parts = [];
|
|
63
|
+
const regex = /\{(\d+)\}/g;
|
|
64
|
+
let lastIndex = 0;
|
|
65
|
+
let match;
|
|
66
|
+
while ((match = regex.exec(question)) !== null) {
|
|
67
|
+
if (match.index > lastIndex) {
|
|
68
|
+
parts.push({ type: 'text', value: question.slice(lastIndex, match.index) });
|
|
69
|
+
}
|
|
70
|
+
parts.push({ type: 'blank', index: Number.parseInt(match[1], 10) });
|
|
71
|
+
lastIndex = match.index + match[0].length;
|
|
72
|
+
}
|
|
73
|
+
if (lastIndex < question.length) {
|
|
74
|
+
parts.push({ type: 'text', value: question.slice(lastIndex) });
|
|
75
|
+
}
|
|
76
|
+
return parts;
|
|
77
|
+
}
|
|
78
|
+
export function normalizeStudentText(value) {
|
|
79
|
+
return value.toString().trim().toLowerCase().replace(/\s+/g, ' ');
|
|
80
|
+
}
|
|
81
|
+
export function isBlankMatch(studentValue, alts) {
|
|
82
|
+
const user = normalizeStudentText(studentValue ?? '');
|
|
83
|
+
if (!user || !alts?.length)
|
|
84
|
+
return false;
|
|
85
|
+
return alts.some((alt) => normalizeStudentText(alt) === user);
|
|
86
|
+
}
|
|
87
|
+
export function padStudentAnswers(raw, blankCount) {
|
|
88
|
+
const values = Array.isArray(raw?.answers)
|
|
89
|
+
? raw.answers.map((item) => String(item ?? ''))
|
|
90
|
+
: Array.isArray(raw)
|
|
91
|
+
? raw.map((item) => String(item ?? ''))
|
|
92
|
+
: typeof raw === 'string'
|
|
93
|
+
? [raw]
|
|
94
|
+
: [];
|
|
95
|
+
const next = [...values];
|
|
96
|
+
while (next.length < blankCount)
|
|
97
|
+
next.push('');
|
|
98
|
+
return next.slice(0, Math.max(blankCount, 0));
|
|
99
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { FillInBlankGroupData, FillInBlankGroupItemData } from '../../_shared/types/fill-in-blank-group.type';
|
|
2
|
+
export declare function createEmptyFillInBlankGroupItem(questionNumber: number): FillInBlankGroupItemData;
|
|
3
|
+
export declare function mapFillInBlankGroupItem(item: unknown, index: number, fallbackAnswer?: unknown): FillInBlankGroupItemData;
|
|
4
|
+
export declare function mapQuestionToFillInBlankGroupData(question: Record<string, unknown> | null | undefined): FillInBlankGroupData;
|