@pie-element/mc-populated-blank 0.2.11 → 0.2.12
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/package.json +3 -14
- package/dist/controller/index.test.d.ts +0 -63
- package/dist/controller/index.test.d.ts.map +0 -1
- package/dist/delivery/AudioPlayer.test.d.ts +0 -2
- package/dist/delivery/AudioPlayer.test.d.ts.map +0 -1
- package/dist/delivery/ChoiceRow.test.d.ts +0 -2
- package/dist/delivery/ChoiceRow.test.d.ts.map +0 -1
- package/dist/delivery/ClozeMarker.test.d.ts +0 -2
- package/dist/delivery/ClozeMarker.test.d.ts.map +0 -1
- package/dist/delivery/McPopulatedBlank.transcript.test.d.ts +0 -2
- package/dist/delivery/McPopulatedBlank.transcript.test.d.ts.map +0 -1
- package/dist/delivery/computeAudioMode.test.d.ts +0 -2
- package/dist/delivery/computeAudioMode.test.d.ts.map +0 -1
- package/dist/delivery/computeChoiceCorrectness.test.d.ts +0 -19
- package/dist/delivery/computeChoiceCorrectness.test.d.ts.map +0 -1
- package/dist/delivery/computeDisplayState.test.d.ts +0 -38
- package/dist/delivery/computeDisplayState.test.d.ts.map +0 -1
- package/dist/delivery/computeLayoutProfile.test.d.ts +0 -2
- package/dist/delivery/computeLayoutProfile.test.d.ts.map +0 -1
- package/dist/delivery/computeLayoutStyle.test.d.ts +0 -2
- package/dist/delivery/computeLayoutStyle.test.d.ts.map +0 -1
- package/src/author/Author.svelte +0 -41
- package/src/author/index.ts +0 -28
- package/src/controller/defaults.ts +0 -61
- package/src/controller/index.test.ts +0 -428
- package/src/controller/index.ts +0 -372
- package/src/delivery/AudioPlayer.svelte +0 -246
- package/src/delivery/AudioPlayer.test.ts +0 -149
- package/src/delivery/ChoiceRow.svelte +0 -222
- package/src/delivery/ChoiceRow.test.ts +0 -179
- package/src/delivery/ClozeMarker.svelte +0 -83
- package/src/delivery/ClozeMarker.test.ts +0 -128
- package/src/delivery/McPopulatedBlank.svelte +0 -739
- package/src/delivery/McPopulatedBlank.transcript.test.ts +0 -154
- package/src/delivery/computeAudioMode.test.ts +0 -40
- package/src/delivery/computeAudioMode.ts +0 -11
- package/src/delivery/computeChoiceCorrectness.test.ts +0 -120
- package/src/delivery/computeChoiceCorrectness.ts +0 -42
- package/src/delivery/computeDisplayState.test.ts +0 -274
- package/src/delivery/computeDisplayState.ts +0 -101
- package/src/delivery/computeLayoutProfile.test.ts +0 -163
- package/src/delivery/computeLayoutProfile.ts +0 -72
- package/src/delivery/computeLayoutStyle.test.ts +0 -168
- package/src/delivery/computeLayoutStyle.ts +0 -128
- package/src/delivery/cqt-css/sel-r1-base.css +0 -59
- package/src/delivery/cqt-css/sel-r1-g-stem.css +0 -14
- package/src/delivery/cqt-css/sel-r1-gg-plus.css +0 -12
- package/src/delivery/cqt-css/sel-r1-ggplus.css +0 -17
- package/src/delivery/cqt-css/sel-r1-gplusggg.css +0 -25
- package/src/delivery/cqt-css/sel-r1-plusggg.css +0 -17
- package/src/delivery/cqt-css/sel-r1-s3.css +0 -85
- package/src/delivery/cqt-css/sel-vic.css +0 -99
- package/src/delivery/cqt-css/sr-vic.css +0 -63
- package/src/delivery/index.ts +0 -94
- package/src/delivery/variant-css-map.ts +0 -124
- package/src/index.iife.ts +0 -13
- package/src/index.ts +0 -1
- package/src/print/Print.svelte +0 -95
- package/src/print/index.ts +0 -18
- package/src/shared/layoutLimits.ts +0 -76
- package/src/shared/types.ts +0 -84
- package/src/types/css-raw.d.ts +0 -4
package/src/controller/index.ts
DELETED
|
@@ -1,372 +0,0 @@
|
|
|
1
|
-
import defaults, { BLANK_TOKEN, DEFAULT_LAYOUT_LIMITS } from './defaults';
|
|
2
|
-
import type {
|
|
3
|
-
McpbChoice,
|
|
4
|
-
McpbQuestion,
|
|
5
|
-
McpbSession,
|
|
6
|
-
McpbEnv,
|
|
7
|
-
McpbCorrectness,
|
|
8
|
-
} from '../shared/types';
|
|
9
|
-
|
|
10
|
-
const isEmptyObject = (value: unknown): boolean =>
|
|
11
|
-
!!value &&
|
|
12
|
-
typeof value === 'object' &&
|
|
13
|
-
!Array.isArray(value) &&
|
|
14
|
-
Object.keys(value as Record<string, unknown>).length === 0;
|
|
15
|
-
|
|
16
|
-
export function countBlankTokens(template: string): number {
|
|
17
|
-
if (!template) return 0;
|
|
18
|
-
const re = /\{\{blank\}\}/g;
|
|
19
|
-
return (template.match(re) || []).length;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export const getCorrectness = (question: McpbQuestion, session: McpbSession): McpbCorrectness => {
|
|
23
|
-
if (!session?.choiceId) {
|
|
24
|
-
return 'unanswered';
|
|
25
|
-
}
|
|
26
|
-
const correct = question?.correctChoiceId || '';
|
|
27
|
-
if (session.choiceId === correct) {
|
|
28
|
-
return 'correct';
|
|
29
|
-
}
|
|
30
|
-
return 'incorrect';
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export const getPartialScore = (_question: McpbQuestion, session: McpbSession) => {
|
|
34
|
-
if (!session || isEmptyObject(session) || !session.choiceId) {
|
|
35
|
-
return 0;
|
|
36
|
-
}
|
|
37
|
-
return 1;
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export const outcome = (question: McpbQuestion, session: McpbSession, env: McpbEnv) =>
|
|
41
|
-
new Promise((resolve) => {
|
|
42
|
-
if (!session || isEmptyObject(session)) {
|
|
43
|
-
resolve({
|
|
44
|
-
score: 0,
|
|
45
|
-
empty: true,
|
|
46
|
-
traceLog: ['Student did not select any answers. Score is 0.'],
|
|
47
|
-
});
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
session = normalizeSession(session);
|
|
52
|
-
const correctness = getCorrectness(question, session);
|
|
53
|
-
|
|
54
|
-
if (correctness === 'unanswered') {
|
|
55
|
-
resolve({
|
|
56
|
-
score: 0,
|
|
57
|
-
empty: true,
|
|
58
|
-
traceLog: ['Student did not select any answers. Score is 0.'],
|
|
59
|
-
});
|
|
60
|
-
return;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const score = correctness === 'correct' ? 1 : 0;
|
|
64
|
-
const traceLog = [
|
|
65
|
-
`Mode: ${env?.mode || 'unknown'}.`,
|
|
66
|
-
`Student selected choice: ${session.choiceId}.`,
|
|
67
|
-
`Correct choice: ${question?.correctChoiceId || 'none'}.`,
|
|
68
|
-
`Final score: ${score}.`,
|
|
69
|
-
];
|
|
70
|
-
resolve({ score, empty: false, traceLog });
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
export const createDefaultModel = (model: McpbQuestion = {}) => ({
|
|
74
|
-
...defaults.model,
|
|
75
|
-
...model,
|
|
76
|
-
layoutLimits: {
|
|
77
|
-
...DEFAULT_LAYOUT_LIMITS,
|
|
78
|
-
...(model?.layoutLimits || {}),
|
|
79
|
-
},
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
export const normalize = (question: McpbQuestion = {}) => createDefaultModel(question);
|
|
83
|
-
|
|
84
|
-
export const normalizeSession = (s: McpbSession): McpbSession => ({ ...s });
|
|
85
|
-
|
|
86
|
-
const shouldShuffleChoices = (question: McpbQuestion) => !!question?.shuffle;
|
|
87
|
-
|
|
88
|
-
const shouldLockChoices = (question: McpbQuestion, env: McpbEnv) => {
|
|
89
|
-
if (question?.lockChoiceOrder) return true;
|
|
90
|
-
if (env?.['@pie-element']?.lockChoiceOrder) return true;
|
|
91
|
-
return env?.role === 'instructor';
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
function shuffleArray<T>(items: T[]): T[] {
|
|
95
|
-
const out = [...items];
|
|
96
|
-
for (let i = out.length - 1; i > 0; i--) {
|
|
97
|
-
const j = Math.floor(Math.random() * (i + 1));
|
|
98
|
-
[out[i], out[j]] = [out[j], out[i]];
|
|
99
|
-
}
|
|
100
|
-
return out;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const getStoredShuffle = (session: McpbSession): string[] =>
|
|
104
|
-
Array.isArray(session?.data?.shuffledValues)
|
|
105
|
-
? session.data.shuffledValues
|
|
106
|
-
: Array.isArray(session?.shuffledValues)
|
|
107
|
-
? session.shuffledValues
|
|
108
|
-
: [];
|
|
109
|
-
|
|
110
|
-
const applyShuffledValues = (
|
|
111
|
-
choices: McpbChoice[],
|
|
112
|
-
shuffledValues: string[],
|
|
113
|
-
choiceKey: keyof McpbChoice
|
|
114
|
-
) => {
|
|
115
|
-
const orderedChoices = shuffledValues
|
|
116
|
-
.map((value) => choices.find((choice) => choice?.[choiceKey] === value))
|
|
117
|
-
.filter((c): c is McpbChoice => !!c);
|
|
118
|
-
|
|
119
|
-
if (orderedChoices.length === choices.length) {
|
|
120
|
-
return orderedChoices;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const orderedValues = new Set(orderedChoices.map((choice) => choice[choiceKey]));
|
|
124
|
-
const leftovers = choices.filter((choice) => !orderedValues.has(choice?.[choiceKey]));
|
|
125
|
-
return [...orderedChoices, ...leftovers];
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
type UpdateSessionFn = (
|
|
129
|
-
id: string,
|
|
130
|
-
element: string,
|
|
131
|
-
data: { shuffledValues: string[] }
|
|
132
|
-
) => Promise<void>;
|
|
133
|
-
|
|
134
|
-
const getOrderedChoices = async (
|
|
135
|
-
question: McpbQuestion,
|
|
136
|
-
session: McpbSession,
|
|
137
|
-
env: McpbEnv,
|
|
138
|
-
updateSession?: UpdateSessionFn
|
|
139
|
-
) => {
|
|
140
|
-
const choices = Array.isArray(question?.choices) ? [...question.choices] : [];
|
|
141
|
-
if (!choices.length || !shouldShuffleChoices(question)) {
|
|
142
|
-
return choices;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
if (shouldLockChoices(question, env || {})) {
|
|
146
|
-
return choices;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const shuffledValues = getStoredShuffle(session);
|
|
150
|
-
if (shuffledValues.length) {
|
|
151
|
-
return applyShuffledValues(choices, shuffledValues, 'id');
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const shuffledChoices = shuffleArray(choices);
|
|
155
|
-
|
|
156
|
-
if (updateSession && typeof updateSession === 'function' && session?.id && session?.element) {
|
|
157
|
-
const shuffledIds = shuffledChoices.map((choice) => choice?.id).filter(Boolean);
|
|
158
|
-
if (shuffledIds.length) {
|
|
159
|
-
await updateSession(session.id, session.element, { shuffledValues: shuffledIds });
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
return shuffledChoices;
|
|
164
|
-
};
|
|
165
|
-
|
|
166
|
-
export const model = async (
|
|
167
|
-
question: McpbQuestion,
|
|
168
|
-
session: McpbSession | null,
|
|
169
|
-
env: McpbEnv | null,
|
|
170
|
-
updateSession?: UpdateSessionFn
|
|
171
|
-
) => {
|
|
172
|
-
const safeSession: McpbSession = session || {};
|
|
173
|
-
const safeEnv: McpbEnv = env || {};
|
|
174
|
-
const normalizedQuestion = normalize(question);
|
|
175
|
-
const choices = await getOrderedChoices(normalizedQuestion, safeSession, safeEnv, updateSession);
|
|
176
|
-
|
|
177
|
-
const out: Record<string, unknown> = {
|
|
178
|
-
prompt: normalizedQuestion.promptEnabled ? normalizedQuestion.prompt : null,
|
|
179
|
-
interactionMode: normalizedQuestion.interactionMode || 'populate_blank',
|
|
180
|
-
layoutProfile: normalizedQuestion.layoutProfile || '',
|
|
181
|
-
choiceLayout: normalizedQuestion.choiceLayout || '',
|
|
182
|
-
layoutProfilePresets:
|
|
183
|
-
normalizedQuestion.layoutProfilePresets &&
|
|
184
|
-
typeof normalizedQuestion.layoutProfilePresets === 'object'
|
|
185
|
-
? normalizedQuestion.layoutProfilePresets
|
|
186
|
-
: {},
|
|
187
|
-
layoutLimits: normalizedQuestion.layoutLimits || { ...DEFAULT_LAYOUT_LIMITS },
|
|
188
|
-
audioButtonSkin:
|
|
189
|
-
normalizedQuestion.audioButtonSkin && typeof normalizedQuestion.audioButtonSkin === 'object'
|
|
190
|
-
? normalizedQuestion.audioButtonSkin
|
|
191
|
-
: null,
|
|
192
|
-
audioButtonSkinsByLocale:
|
|
193
|
-
normalizedQuestion.audioButtonSkinsByLocale &&
|
|
194
|
-
typeof normalizedQuestion.audioButtonSkinsByLocale === 'object'
|
|
195
|
-
? normalizedQuestion.audioButtonSkinsByLocale
|
|
196
|
-
: {},
|
|
197
|
-
uiText:
|
|
198
|
-
normalizedQuestion.uiText && typeof normalizedQuestion.uiText === 'object'
|
|
199
|
-
? normalizedQuestion.uiText
|
|
200
|
-
: {},
|
|
201
|
-
sentenceHtml: normalizedQuestion.sentenceHtml || null,
|
|
202
|
-
template: normalizedQuestion.template,
|
|
203
|
-
choiceMode: normalizedQuestion.choiceMode,
|
|
204
|
-
choices,
|
|
205
|
-
hasAudio: normalizedQuestion.hasAudio,
|
|
206
|
-
autoplayAudioEnabled: !!normalizedQuestion.autoplayAudioEnabled,
|
|
207
|
-
completeAudioEnabled: !!normalizedQuestion.completeAudioEnabled,
|
|
208
|
-
audioUrl: normalizedQuestion.hasAudio ? normalizedQuestion.audioUrl : null,
|
|
209
|
-
audioTranscript: normalizedQuestion.hasAudio ? normalizedQuestion.audioTranscript : null,
|
|
210
|
-
showVisibleTranscript: !!normalizedQuestion.showVisibleTranscript,
|
|
211
|
-
customType: normalizedQuestion.customType || '',
|
|
212
|
-
useFeatureButtonAudio:
|
|
213
|
-
typeof normalizedQuestion.useFeatureButtonAudio === 'boolean'
|
|
214
|
-
? normalizedQuestion.useFeatureButtonAudio
|
|
215
|
-
: undefined,
|
|
216
|
-
locale: normalizedQuestion.locale || '',
|
|
217
|
-
disabled: safeEnv.mode !== 'gather',
|
|
218
|
-
mode: safeEnv.mode,
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
if (safeEnv.mode === 'evaluate') {
|
|
222
|
-
const correctness = getCorrectness(normalizedQuestion, safeSession);
|
|
223
|
-
out.correctness = correctness;
|
|
224
|
-
out.responseCorrect = correctness === 'correct';
|
|
225
|
-
out.correctChoiceId = normalizedQuestion.correctChoiceId;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
if (safeEnv.role === 'instructor' && (safeEnv.mode === 'view' || safeEnv.mode === 'evaluate')) {
|
|
229
|
-
out.teacherInstructions = normalizedQuestion.teacherInstructionsEnabled
|
|
230
|
-
? normalizedQuestion.teacherInstructions
|
|
231
|
-
: null;
|
|
232
|
-
} else {
|
|
233
|
-
out.teacherInstructions = null;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
return out;
|
|
237
|
-
};
|
|
238
|
-
|
|
239
|
-
export const createCorrectResponseSession = (question: McpbQuestion, env: McpbEnv) => {
|
|
240
|
-
return new Promise((resolve) => {
|
|
241
|
-
if (env.mode !== 'evaluate' && env.role === 'instructor') {
|
|
242
|
-
resolve({
|
|
243
|
-
id: '1',
|
|
244
|
-
element: 'mc-populated-blank',
|
|
245
|
-
choiceId: question?.correctChoiceId || '',
|
|
246
|
-
});
|
|
247
|
-
} else {
|
|
248
|
-
resolve(null);
|
|
249
|
-
}
|
|
250
|
-
});
|
|
251
|
-
};
|
|
252
|
-
|
|
253
|
-
export const validate = (question: McpbQuestion = {}, _config: Record<string, unknown> = {}) => {
|
|
254
|
-
const errors: Record<string, string> = {};
|
|
255
|
-
|
|
256
|
-
if (question.promptEnabled) {
|
|
257
|
-
const p = question.prompt?.trim() || '';
|
|
258
|
-
if (!p || p === '<p></p>') {
|
|
259
|
-
errors.prompt = 'Prompt is required when prompt is enabled';
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const interactionMode = question.interactionMode || 'populate_blank';
|
|
264
|
-
const template = question.template || '';
|
|
265
|
-
const n = countBlankTokens(template);
|
|
266
|
-
if (interactionMode === 'audio_mc_only') {
|
|
267
|
-
if (n > 0) {
|
|
268
|
-
errors.template = `Template cannot contain ${BLANK_TOKEN} in audio-only mode`;
|
|
269
|
-
}
|
|
270
|
-
} else if (interactionMode === 'populate_blank') {
|
|
271
|
-
if (n === 0) {
|
|
272
|
-
errors.template = `Template must contain exactly one ${BLANK_TOKEN} placeholder`;
|
|
273
|
-
} else if (n > 1) {
|
|
274
|
-
errors.template = `Template must contain only one ${BLANK_TOKEN} placeholder`;
|
|
275
|
-
}
|
|
276
|
-
} else {
|
|
277
|
-
errors.interactionMode = 'Unknown interaction mode';
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
const choices = Array.isArray(question.choices) ? question.choices : [];
|
|
281
|
-
if (choices.length < 2) {
|
|
282
|
-
errors.choices = 'At least two choices are required';
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
const mode = question.choiceMode || 'text';
|
|
286
|
-
for (let i = 0; i < choices.length; i++) {
|
|
287
|
-
const c = choices[i];
|
|
288
|
-
if (!c?.id) {
|
|
289
|
-
errors.choices = `Choice ${i + 1} is missing an id`;
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
if (mode === 'text') {
|
|
293
|
-
const lbl = (c.labelHtml || '').trim();
|
|
294
|
-
if (!lbl || lbl === '<p></p>') {
|
|
295
|
-
errors.choices = `Choice ${i + 1} needs label text`;
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
} else {
|
|
299
|
-
if (!c.imageUrl?.trim()) {
|
|
300
|
-
errors.choices = `Choice ${i + 1} needs an image URL`;
|
|
301
|
-
break;
|
|
302
|
-
}
|
|
303
|
-
if (!c.imageAlt?.trim()) {
|
|
304
|
-
errors.choices = `Choice ${i + 1} needs image alt text`;
|
|
305
|
-
break;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
const correct = question.correctChoiceId;
|
|
311
|
-
if (!correct || !choices.some((c) => c.id === correct)) {
|
|
312
|
-
errors.correctChoiceId = 'Correct choice must match one of the choice ids';
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
if (question.hasAudio) {
|
|
316
|
-
const hasAudioUrl = !!question.audioUrl?.trim();
|
|
317
|
-
if (!hasAudioUrl) {
|
|
318
|
-
errors.audioUrl = 'A playable audio URL is required when audio is enabled';
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
const limits = question?.layoutLimits;
|
|
323
|
-
if (limits && typeof limits === 'object') {
|
|
324
|
-
const numericLimitKeys = [
|
|
325
|
-
'blankStandaloneWidthRem',
|
|
326
|
-
'blankWideWidthRem',
|
|
327
|
-
'blankUnderlineWidthPx',
|
|
328
|
-
'blankUnderlineWideWidthPx',
|
|
329
|
-
'horizontalChoiceWidthPx',
|
|
330
|
-
'horizontalChoiceWidthVw',
|
|
331
|
-
'horizontalChoiceTileMinHeightRem',
|
|
332
|
-
'horizontalChoiceContentMinHeightRem',
|
|
333
|
-
'selectedImageMaxHeightRem',
|
|
334
|
-
'choiceImageMaxHeightRem',
|
|
335
|
-
'listenButtonSizePx',
|
|
336
|
-
'stimulusMinColumnPx',
|
|
337
|
-
'textMinColumnPx',
|
|
338
|
-
'legendMaxChars',
|
|
339
|
-
'choiceGroupGapRem',
|
|
340
|
-
'choiceRowGapRem',
|
|
341
|
-
'toggleButtonGapRem',
|
|
342
|
-
'horizontalChoiceRadioTopMarginRem',
|
|
343
|
-
'audioBlankTemplateMarginTopRem',
|
|
344
|
-
'audioBlankTemplateMarginBottomRem',
|
|
345
|
-
'audioInstructionsMaxWidthPx',
|
|
346
|
-
'narrowHorizontalChoiceMaxWidthPx',
|
|
347
|
-
'stimulusGridColumnGapRem',
|
|
348
|
-
'stimulusGridRowGapRem',
|
|
349
|
-
'stimulusSentenceMarginTopRem',
|
|
350
|
-
'stimulusChoicesMarginTopRem',
|
|
351
|
-
'tokenGridColumnGapRem',
|
|
352
|
-
'tokenGridRowGapRem',
|
|
353
|
-
'tokenTemplateMarginTopRem',
|
|
354
|
-
'tokenInlineTokenGapRem',
|
|
355
|
-
'tokenChoicesMarginTopRem',
|
|
356
|
-
'inlineGridColumnGapRem',
|
|
357
|
-
'inlineGridRowGapRem',
|
|
358
|
-
'inlineTemplateMarginTopRem',
|
|
359
|
-
'inlineChoicesMarginTopRem',
|
|
360
|
-
] as const;
|
|
361
|
-
for (const key of numericLimitKeys) {
|
|
362
|
-
if (limits[key] === undefined || limits[key] === null) continue;
|
|
363
|
-
const value = Number(limits[key]);
|
|
364
|
-
if (!Number.isFinite(value) || value <= 0) {
|
|
365
|
-
errors.layoutLimits = `${key} must be a positive number when provided`;
|
|
366
|
-
break;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
return errors;
|
|
372
|
-
};
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { computeAudioMode } from './computeAudioMode';
|
|
3
|
-
|
|
4
|
-
interface AudioButtonSkin {
|
|
5
|
-
silentUrl: string;
|
|
6
|
-
playingUrl: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
interface UiText {
|
|
10
|
-
clickToEnableAutoplay: string;
|
|
11
|
-
transcriptLabel: string;
|
|
12
|
-
audioResourceUnavailable: string;
|
|
13
|
-
listenSilentAlt?: string;
|
|
14
|
-
listenPlayingAlt?: string;
|
|
15
|
-
listenSilentAltEs?: string;
|
|
16
|
-
listenPlayingAltEs?: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const AUDIO_PLAYBACK = {
|
|
20
|
-
IDLE: 'idle',
|
|
21
|
-
PLAYING: 'playing',
|
|
22
|
-
BLOCKED: 'blocked',
|
|
23
|
-
PAUSED: 'paused',
|
|
24
|
-
} as const;
|
|
25
|
-
type AudioPlaybackState = (typeof AUDIO_PLAYBACK)[keyof typeof AUDIO_PLAYBACK];
|
|
26
|
-
|
|
27
|
-
let {
|
|
28
|
-
hasAudio = false,
|
|
29
|
-
audioUrl,
|
|
30
|
-
useFeatureButtonAudio = false,
|
|
31
|
-
autoplayEnabled = false,
|
|
32
|
-
audioTranscript,
|
|
33
|
-
showVisibleTranscript,
|
|
34
|
-
transcriptId,
|
|
35
|
-
featureAudioSkin,
|
|
36
|
-
uiText,
|
|
37
|
-
locale = '',
|
|
38
|
-
onaudiostarted,
|
|
39
|
-
onaudioended,
|
|
40
|
-
}: {
|
|
41
|
-
hasAudio?: boolean;
|
|
42
|
-
audioUrl?: string;
|
|
43
|
-
useFeatureButtonAudio?: boolean;
|
|
44
|
-
autoplayEnabled?: boolean;
|
|
45
|
-
audioTranscript?: string;
|
|
46
|
-
showVisibleTranscript: boolean;
|
|
47
|
-
transcriptId: string;
|
|
48
|
-
featureAudioSkin: AudioButtonSkin;
|
|
49
|
-
uiText: UiText;
|
|
50
|
-
locale?: string;
|
|
51
|
-
onaudiostarted?: () => void;
|
|
52
|
-
onaudioended?: () => void;
|
|
53
|
-
} = $props();
|
|
54
|
-
|
|
55
|
-
let audioEl = $state<HTMLAudioElement | null>(null);
|
|
56
|
-
let featureAudioButtonEl = $state<HTMLButtonElement | null>(null);
|
|
57
|
-
let autoplayEnableButtonEl = $state<HTMLButtonElement | null>(null);
|
|
58
|
-
let audioPlaybackState = $state<AudioPlaybackState>(AUDIO_PLAYBACK.IDLE);
|
|
59
|
-
|
|
60
|
-
const audioMode = $derived(computeAudioMode({ hasAudio, audioUrl, useFeatureButtonAudio }));
|
|
61
|
-
const isMediaPlaying = $derived(audioPlaybackState === AUDIO_PLAYBACK.PLAYING);
|
|
62
|
-
const autoPlayPromptOpen = $derived(audioPlaybackState === AUDIO_PLAYBACK.BLOCKED);
|
|
63
|
-
const audioErrorMessage = $derived(audioMode === 'error' ? uiText.audioResourceUnavailable : '');
|
|
64
|
-
|
|
65
|
-
// Reset playback state when audioUrl changes so autoplay re-fires for a new question.
|
|
66
|
-
$effect(() => {
|
|
67
|
-
audioUrl;
|
|
68
|
-
audioPlaybackState = AUDIO_PLAYBACK.IDLE;
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
function playFeatureAudio() {
|
|
72
|
-
if (audioMode !== 'none' && audioMode !== 'error' && audioEl) {
|
|
73
|
-
audioEl.play().catch(() => {});
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function handleEnableAutoplayClick() {
|
|
78
|
-
if (audioMode !== 'none' && audioMode !== 'error' && audioEl) {
|
|
79
|
-
audioEl.play().finally(() => {
|
|
80
|
-
if (audioPlaybackState === AUDIO_PLAYBACK.BLOCKED) audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function useListener<K extends keyof HTMLElementEventMap>(
|
|
86
|
-
getEl: () => EventTarget | null | undefined,
|
|
87
|
-
event: K,
|
|
88
|
-
handler: (e: HTMLElementEventMap[K]) => void
|
|
89
|
-
) {
|
|
90
|
-
$effect(() => {
|
|
91
|
-
const el = getEl();
|
|
92
|
-
if (!el) return;
|
|
93
|
-
el.addEventListener(event, handler as EventListener);
|
|
94
|
-
return () => el.removeEventListener(event, handler as EventListener);
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
useListener(
|
|
99
|
-
() => audioEl,
|
|
100
|
-
'playing',
|
|
101
|
-
() => {
|
|
102
|
-
audioPlaybackState = AUDIO_PLAYBACK.PLAYING;
|
|
103
|
-
onaudiostarted?.();
|
|
104
|
-
}
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
useListener(
|
|
108
|
-
() => audioEl,
|
|
109
|
-
'ended',
|
|
110
|
-
() => {
|
|
111
|
-
audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
|
|
112
|
-
onaudioended?.();
|
|
113
|
-
}
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
useListener(
|
|
117
|
-
() => featureAudioButtonEl,
|
|
118
|
-
'click',
|
|
119
|
-
() => playFeatureAudio()
|
|
120
|
-
);
|
|
121
|
-
useListener(
|
|
122
|
-
() => autoplayEnableButtonEl,
|
|
123
|
-
'click',
|
|
124
|
-
() => handleEnableAutoplayClick()
|
|
125
|
-
);
|
|
126
|
-
|
|
127
|
-
$effect(() => {
|
|
128
|
-
if (!autoplayEnabled || audioMode === 'none' || audioMode === 'error') return;
|
|
129
|
-
if (audioPlaybackState !== AUDIO_PLAYBACK.IDLE) return;
|
|
130
|
-
if (audioEl && audioUrl) {
|
|
131
|
-
audioPlaybackState = AUDIO_PLAYBACK.PAUSED;
|
|
132
|
-
audioEl.play().catch(() => {
|
|
133
|
-
audioPlaybackState = AUDIO_PLAYBACK.BLOCKED;
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
function speechButtonLabel(loc = '') {
|
|
139
|
-
return loc.toLowerCase().startsWith('es') ? 'Escuchar' : 'Listen';
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function silentAlt(loc = '') {
|
|
143
|
-
return loc.toLowerCase().startsWith('es')
|
|
144
|
-
? (uiText.listenSilentAltEs ?? 'Escuchar. Repetir las instrucciones.')
|
|
145
|
-
: (uiText.listenSilentAlt ?? 'Repeat instructions');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function playingAlt(loc = '') {
|
|
149
|
-
return loc.toLowerCase().startsWith('es')
|
|
150
|
-
? (uiText.listenPlayingAltEs ?? 'Escuchar. Estas son las instrucciones.')
|
|
151
|
-
: (uiText.listenPlayingAlt ?? 'Instructions are playing');
|
|
152
|
-
}
|
|
153
|
-
</script>
|
|
154
|
-
|
|
155
|
-
{#if audioMode !== 'none'}
|
|
156
|
-
<div class="mb-4 audio-container pie-audio-container">
|
|
157
|
-
{#if audioMode === 'feature-button'}
|
|
158
|
-
<audio
|
|
159
|
-
bind:this={audioEl}
|
|
160
|
-
class="sr-only pie-audio-player"
|
|
161
|
-
preload="metadata"
|
|
162
|
-
src={audioUrl}
|
|
163
|
-
aria-hidden="true"
|
|
164
|
-
tabindex="-1"
|
|
165
|
-
></audio>
|
|
166
|
-
<button
|
|
167
|
-
bind:this={featureAudioButtonEl}
|
|
168
|
-
class="listen-button pie-listen-button rli-feature-audio"
|
|
169
|
-
type="button"
|
|
170
|
-
aria-label={speechButtonLabel(locale)}
|
|
171
|
-
>
|
|
172
|
-
<img
|
|
173
|
-
class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? '' : 'listen-active'}`}
|
|
174
|
-
src={featureAudioSkin.silentUrl}
|
|
175
|
-
alt={silentAlt(locale)}
|
|
176
|
-
/>
|
|
177
|
-
<img
|
|
178
|
-
class={`listen-feature-icon pie-listen-icon rli-feature-listen ${isMediaPlaying ? 'listen-active' : ''}`}
|
|
179
|
-
src={featureAudioSkin.playingUrl}
|
|
180
|
-
alt={playingAlt(locale)}
|
|
181
|
-
/>
|
|
182
|
-
</button>
|
|
183
|
-
{:else if audioMode === 'controls'}
|
|
184
|
-
<audio
|
|
185
|
-
bind:this={audioEl}
|
|
186
|
-
controls
|
|
187
|
-
class="w-full max-w-md pie-audio-player"
|
|
188
|
-
preload="metadata"
|
|
189
|
-
src={audioUrl}
|
|
190
|
-
aria-describedby={audioTranscript ? transcriptId : undefined}
|
|
191
|
-
>
|
|
192
|
-
<track kind="captions" />
|
|
193
|
-
</audio>
|
|
194
|
-
{#if autoPlayPromptOpen}
|
|
195
|
-
<button
|
|
196
|
-
bind:this={autoplayEnableButtonEl}
|
|
197
|
-
class="mt-2 text-sm underline pie-audio-autoplay-enable"
|
|
198
|
-
type="button"
|
|
199
|
-
>
|
|
200
|
-
{uiText.clickToEnableAutoplay}
|
|
201
|
-
</button>
|
|
202
|
-
{/if}
|
|
203
|
-
{:else if audioMode === 'error'}
|
|
204
|
-
<p class="text-sm text-red-700 pie-audio-error" role="alert">{audioErrorMessage}</p>
|
|
205
|
-
{/if}
|
|
206
|
-
</div>
|
|
207
|
-
{/if}
|
|
208
|
-
|
|
209
|
-
<style>
|
|
210
|
-
.listen-button {
|
|
211
|
-
width: var(--mpb-listen-button-size, 128px);
|
|
212
|
-
height: var(--mpb-listen-button-size, 128px);
|
|
213
|
-
border: 0;
|
|
214
|
-
padding: 0;
|
|
215
|
-
background-color: transparent;
|
|
216
|
-
cursor: pointer;
|
|
217
|
-
z-index: 1;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
.listen-button:hover {
|
|
221
|
-
background-color: #e2f1fe;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
.listen-feature-icon {
|
|
225
|
-
width: var(--mpb-listen-button-size, 128px);
|
|
226
|
-
height: var(--mpb-listen-button-size, 128px);
|
|
227
|
-
object-fit: contain;
|
|
228
|
-
display: none;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
.listen-feature-icon.listen-active {
|
|
232
|
-
display: block;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
.sr-only {
|
|
236
|
-
position: absolute;
|
|
237
|
-
width: 1px;
|
|
238
|
-
height: 1px;
|
|
239
|
-
padding: 0;
|
|
240
|
-
margin: -1px;
|
|
241
|
-
overflow: hidden;
|
|
242
|
-
clip: rect(0, 0, 0, 0);
|
|
243
|
-
white-space: nowrap;
|
|
244
|
-
border: 0;
|
|
245
|
-
}
|
|
246
|
-
</style>
|