@longsightgroup/qti3-player 0.2.1 → 0.3.0

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 (116) hide show
  1. package/dist/icons.d.ts +8 -0
  2. package/dist/icons.d.ts.map +1 -0
  3. package/dist/icons.js +45 -0
  4. package/dist/icons.js.map +1 -0
  5. package/dist/index.d.ts +3 -134
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -4712
  8. package/dist/index.js.map +1 -1
  9. package/dist/interaction-support.d.ts +34 -0
  10. package/dist/interaction-support.d.ts.map +1 -0
  11. package/dist/interaction-support.js +189 -0
  12. package/dist/interaction-support.js.map +1 -0
  13. package/dist/movement.d.ts +3 -0
  14. package/dist/movement.d.ts.map +1 -0
  15. package/dist/movement.js +21 -0
  16. package/dist/movement.js.map +1 -0
  17. package/dist/player-element.d.ts +60 -0
  18. package/dist/player-element.d.ts.map +1 -0
  19. package/dist/player-element.js +367 -0
  20. package/dist/player-element.js.map +1 -0
  21. package/dist/player-locale.d.ts +6 -0
  22. package/dist/player-locale.d.ts.map +1 -0
  23. package/dist/player-locale.js +205 -0
  24. package/dist/player-locale.js.map +1 -0
  25. package/dist/player-messages.d.ts +40 -0
  26. package/dist/player-messages.d.ts.map +1 -0
  27. package/dist/player-messages.js +2 -0
  28. package/dist/player-messages.js.map +1 -0
  29. package/dist/player-styles.d.ts +3 -0
  30. package/dist/player-styles.d.ts.map +1 -0
  31. package/dist/player-styles.js +24 -0
  32. package/dist/player-styles.js.map +1 -0
  33. package/dist/player-types.d.ts +71 -0
  34. package/dist/player-types.d.ts.map +1 -0
  35. package/dist/player-types.js +2 -0
  36. package/dist/player-types.js.map +1 -0
  37. package/dist/player-validation-dom.d.ts +3 -0
  38. package/dist/player-validation-dom.d.ts.map +1 -0
  39. package/dist/player-validation-dom.js +28 -0
  40. package/dist/player-validation-dom.js.map +1 -0
  41. package/dist/player-validation.d.ts +13 -0
  42. package/dist/player-validation.d.ts.map +1 -0
  43. package/dist/player-validation.js +123 -0
  44. package/dist/player-validation.js.map +1 -0
  45. package/dist/portable-custom-support.d.ts +11 -0
  46. package/dist/portable-custom-support.d.ts.map +1 -0
  47. package/dist/portable-custom-support.js +70 -0
  48. package/dist/portable-custom-support.js.map +1 -0
  49. package/dist/response-limits.d.ts +9 -0
  50. package/dist/response-limits.d.ts.map +1 -0
  51. package/dist/response-limits.js +44 -0
  52. package/dist/response-limits.js.map +1 -0
  53. package/package.json +4 -4
  54. package/src/content/content-dom.ts +274 -0
  55. package/src/content/content-renderer.ts +114 -0
  56. package/src/controls/remove-button.ts +13 -0
  57. package/src/icons.ts +47 -0
  58. package/src/index.ts +26 -5307
  59. package/src/interaction-support.ts +263 -0
  60. package/src/interactions/choice-interaction.ts +92 -0
  61. package/src/interactions/drawing-interaction.ts +447 -0
  62. package/src/interactions/end-attempt-interaction.ts +19 -0
  63. package/src/interactions/gap-match-interaction.ts +337 -0
  64. package/src/interactions/graphic-associate-interaction.ts +324 -0
  65. package/src/interactions/graphic-context.ts +33 -0
  66. package/src/interactions/hotspot-interaction.ts +87 -0
  67. package/src/interactions/hottext-interaction.ts +81 -0
  68. package/src/interactions/inline-choice-interaction.ts +45 -0
  69. package/src/interactions/inline-controls.ts +21 -0
  70. package/src/interactions/interaction-diagnostics.ts +159 -0
  71. package/src/interactions/interaction-dispatch.ts +9 -0
  72. package/src/interactions/interaction-label.ts +10 -0
  73. package/src/interactions/interaction-registry.ts +209 -0
  74. package/src/interactions/match-interaction.ts +199 -0
  75. package/src/interactions/object-asset.ts +212 -0
  76. package/src/interactions/pair-interaction.ts +147 -0
  77. package/src/interactions/point-value.ts +41 -0
  78. package/src/interactions/portable-custom-interaction.ts +139 -0
  79. package/src/interactions/position-object-interaction.ts +210 -0
  80. package/src/interactions/routing.ts +27 -0
  81. package/src/interactions/select-point-interaction.ts +185 -0
  82. package/src/interactions/shared.ts +56 -0
  83. package/src/interactions/text-interaction.ts +127 -0
  84. package/src/interactions/unsupported-interaction.ts +25 -0
  85. package/src/interactions/upload-interaction.ts +16 -0
  86. package/src/movement.ts +29 -0
  87. package/src/player/attempt-availability.ts +36 -0
  88. package/src/player/content-state.ts +63 -0
  89. package/src/player/dynamic-body.ts +40 -0
  90. package/src/player/feedback-panel.ts +23 -0
  91. package/src/player/fetch-xml.ts +8 -0
  92. package/src/player/interaction-render.ts +89 -0
  93. package/src/player/render-shell.ts +44 -0
  94. package/src/player/resolve-assets.ts +12 -0
  95. package/src/player/validation-messages.ts +42 -0
  96. package/src/player-element.ts +493 -0
  97. package/src/player-locale.ts +232 -0
  98. package/src/player-messages.ts +31 -0
  99. package/src/player-styles.ts +25 -0
  100. package/src/player-types.ts +99 -0
  101. package/src/player-validation-dom.ts +31 -0
  102. package/src/player-validation.ts +158 -0
  103. package/src/portable-custom-support.ts +74 -0
  104. package/src/reorder/a11y.ts +40 -0
  105. package/src/reorder/graphic-order-interaction.ts +260 -0
  106. package/src/reorder/list-controls.ts +114 -0
  107. package/src/reorder/order-interaction.ts +75 -0
  108. package/src/response-limits.ts +47 -0
  109. package/src/styles/base-styles.ts +117 -0
  110. package/src/styles/choice-hottext-styles.ts +75 -0
  111. package/src/styles/control-styles.ts +113 -0
  112. package/src/styles/drawing-styles.ts +29 -0
  113. package/src/styles/gap-match-styles.ts +32 -0
  114. package/src/styles/graphic-styles.ts +294 -0
  115. package/src/styles/match-pair-styles.ts +61 -0
  116. package/src/styles/text-slider-styles.ts +34 -0
package/dist/index.js CHANGED
@@ -1,4713 +1,2 @@
1
- import { assertQtiAttemptStateV1, createItemSession, createCatalogSupportResolution, createTextToSpeechTraversal, parseQtiXml, visibleModalFeedback, } from "@longsightgroup/qti3-core";
2
- const HTMLElementBase = globalThis.HTMLElement ??
3
- class {
4
- replaceChildren() { }
5
- dispatchEvent() {
6
- return true;
7
- }
8
- };
9
- export class QtiAssessmentItemPlayer extends HTMLElementBase {
10
- static get observedAttributes() {
11
- return ["language-of-interface", "locale"];
12
- }
13
- documentModel;
14
- session;
15
- resolveAsset;
16
- validationMessages = [];
17
- languageOfInterfaceOverride;
18
- messageOverrides = {};
19
- sessionControl = {
20
- validateResponses: true,
21
- showFeedback: true,
22
- };
23
- get languageOfInterface() {
24
- return (this.languageOfInterfaceOverride ??
25
- this.getAttribute?.("language-of-interface") ??
26
- this.getAttribute?.("locale") ??
27
- defaultPlayerLocale(this));
28
- }
29
- set languageOfInterface(value) {
30
- this.languageOfInterfaceOverride = normalizedLocale(value);
31
- this.rerenderIfLoaded();
32
- }
33
- get locale() {
34
- return this.languageOfInterface;
35
- }
36
- set locale(value) {
37
- this.languageOfInterface = value;
38
- }
39
- get messages() {
40
- return this.messageOverrides;
41
- }
42
- set messages(value) {
43
- this.messageOverrides = value ?? {};
44
- this.rerenderIfLoaded();
45
- }
46
- attributeChangedCallback(name, oldValue, newValue) {
47
- if ((name !== "language-of-interface" && name !== "locale") || oldValue === newValue) {
48
- return;
49
- }
50
- this.rerenderIfLoaded();
51
- }
52
- async loadXml(xml, options = {}) {
53
- this.sessionControl = {
54
- validateResponses: options.sessionControl?.validateResponses ?? true,
55
- showFeedback: options.sessionControl?.showFeedback ?? true,
56
- };
57
- this.resolveAsset = options.resolveAsset;
58
- const result = parseQtiXml(xml);
59
- this.dispatchEvent(new CustomEvent("qti-diagnostics", { detail: { diagnostics: result.diagnostics } }));
60
- if (!result.document) {
61
- this.replaceChildren(errorView("Unable to parse QTI item."));
62
- return;
63
- }
64
- this.documentModel = result.document;
65
- this.session = createItemSession(result.document, options.state);
66
- this.validationMessages = cloneDiagnostics(options.state?.validationMessages ?? []);
67
- if (options.status)
68
- this.session.setStatus(options.status);
69
- this.render();
70
- this.renderValidationMessages();
71
- this.updateAttemptAvailability();
72
- this.dispatchPlayerEvent("qti-ready", { item: result.document.item });
73
- this.emitStateChange();
74
- }
75
- async loadUrl(url, options = {}) {
76
- const fetchXml = options.fetchXml ?? defaultFetchXml;
77
- await this.loadXml(await fetchXml(url), options);
78
- }
79
- scoreAttempt(options = {}) {
80
- const session = this.session;
81
- if (!session)
82
- return undefined;
83
- const shouldValidateResponses = options.validateResponses ?? this.sessionControl.validateResponses;
84
- const validationMessages = shouldValidateResponses ? this.validateResponses() : [];
85
- if (validationMessages.length > 0) {
86
- this.validationMessages = cloneDiagnostics(validationMessages);
87
- this.renderValidationMessages();
88
- const state = this.serialize();
89
- if (!state)
90
- return undefined;
91
- this.dispatchPlayerEvent("qti-validation", {
92
- validationMessages: cloneDiagnostics(this.validationMessages),
93
- state,
94
- });
95
- this.emitStateChange(state);
96
- return undefined;
97
- }
98
- this.validationMessages = [];
99
- this.renderValidationMessages();
100
- const result = session.score();
101
- this.dispatchPlayerEvent("qti-score", result);
102
- this.updateDynamicBodyState();
103
- this.updateAttemptAvailability();
104
- if (this.sessionControl.showFeedback)
105
- this.renderFeedback(result.outcomes);
106
- this.emitStateChange(result.state);
107
- return result;
108
- }
109
- reset() {
110
- if (!this.documentModel)
111
- return;
112
- this.session = createItemSession(this.documentModel);
113
- this.validationMessages = [];
114
- this.render();
115
- this.updateAttemptAvailability();
116
- this.dispatchEvent(new CustomEvent("qti-reset", { detail: { state: this.serialize() } }));
117
- this.emitStateChange();
118
- }
119
- restore(state) {
120
- if (!this.documentModel) {
121
- throw new Error("Cannot restore QTI state before loading an item.");
122
- }
123
- assertQtiAttemptStateV1(state);
124
- if (state.itemIdentifier !== this.documentModel.item.identifier) {
125
- throw new Error(`Cannot restore state for ${state.itemIdentifier} into ${this.documentModel.item.identifier}.`);
126
- }
127
- this.session = createItemSession(this.documentModel, state);
128
- this.validationMessages = cloneDiagnostics(state.validationMessages);
129
- this.render();
130
- this.renderValidationMessages();
131
- this.updateAttemptAvailability();
132
- this.dispatchEvent(new CustomEvent("qti-restore", { detail: { state: this.serialize() } }));
133
- this.emitStateChange();
134
- }
135
- suspend() {
136
- if (!this.session)
137
- return;
138
- this.session.setStatus("suspended");
139
- const state = this.serialize();
140
- if (!state)
141
- return;
142
- this.dispatchPlayerEvent("qti-suspend", { state });
143
- this.emitStateChange(state);
144
- }
145
- endAttempt(options = {}) {
146
- const result = this.scoreAttempt(options);
147
- if (!result)
148
- return;
149
- if (!this.documentModel?.item.adaptive ||
150
- result.state.outcomes.completionStatus === "completed") {
151
- this.session?.setStatus("completed");
152
- }
153
- this.updateAttemptAvailability();
154
- const state = this.serialize();
155
- if (!state)
156
- return;
157
- this.dispatchPlayerEvent("qti-endattempt", { state });
158
- this.emitStateChange(state);
159
- }
160
- serialize() {
161
- const state = this.session?.serialize();
162
- if (state)
163
- state.validationMessages = cloneDiagnostics(this.validationMessages);
164
- return state;
165
- }
166
- getTextToSpeechTraversal() {
167
- if (!this.documentModel)
168
- return undefined;
169
- return createTextToSpeechTraversal(this.documentModel);
170
- }
171
- getCatalogSupportResolution(options = {}) {
172
- if (!this.documentModel)
173
- return undefined;
174
- return createCatalogSupportResolution(this.documentModel, options);
175
- }
176
- emitStateChange(state = this.serialize()) {
177
- if (!state)
178
- return;
179
- this.dispatchPlayerEvent("qti-statechange", { state });
180
- }
181
- dispatchPlayerEvent(type, detail) {
182
- this.dispatchEvent(new CustomEvent(type, { detail }));
183
- }
184
- playerMessages() {
185
- return resolvePlayerMessages(this.languageOfInterface, this.messageOverrides);
186
- }
187
- rerenderIfLoaded() {
188
- if (!this.documentModel)
189
- return;
190
- this.render();
191
- this.renderValidationMessages();
192
- this.updateAttemptAvailability();
193
- }
194
- render() {
195
- const documentModel = this.documentModel;
196
- if (!documentModel)
197
- return;
198
- this.applyDefaultStyles();
199
- const root = document.createElement("article");
200
- root.className = "qti3-player";
201
- if (documentModel.item.language) {
202
- root.lang = documentModel.item.language;
203
- root.setAttribute("xml:lang", documentModel.item.language);
204
- }
205
- root.append(playerStyleElement());
206
- if (documentModel.item.prompt && documentModel.item.body.length === 0) {
207
- const prompt = document.createElement("p");
208
- prompt.className = "qti3-item-prompt";
209
- prompt.textContent = documentModel.item.prompt;
210
- root.append(prompt);
211
- }
212
- if (documentModel.item.body.length > 0) {
213
- const body = document.createElement("div");
214
- body.className = "qti3-item-body";
215
- body.append(...this.renderContentNodes(documentModel.item.body));
216
- root.append(body);
217
- }
218
- else {
219
- for (const interaction of documentModel.item.interactions) {
220
- root.append(this.renderInteraction(interaction));
221
- }
222
- }
223
- const feedback = document.createElement("section");
224
- feedback.className = "qti3-feedback";
225
- feedback.role = "status";
226
- feedback.setAttribute("aria-live", "polite");
227
- feedback.hidden = true;
228
- root.append(feedback);
229
- this.resolveRenderedAssets(root);
230
- this.replaceChildren(root);
231
- }
232
- renderInteraction(interaction) {
233
- const messages = this.playerMessages();
234
- const field = document.createElement("section");
235
- field.className = `qti3-interaction qti3-${interaction.type}`;
236
- field.classList.add(...qtiSharedClassNames(interaction.attributes.class));
237
- field.dataset.interactionType = interaction.type;
238
- if (interaction.responseIdentifier)
239
- field.dataset.responseIdentifier = interaction.responseIdentifier;
240
- const heading = document.createElement("h3");
241
- copySafeAttributes(heading, interaction.promptAttributes ?? {});
242
- heading.textContent = interactionLabel(interaction);
243
- field.append(heading);
244
- if (interaction.responseIdentifier) {
245
- field.append(validationMessageElement(interaction.responseIdentifier));
246
- }
247
- const responseIdentifier = interaction.responseIdentifier;
248
- const update = (value) => {
249
- if (this.attemptIsCompleted())
250
- return;
251
- if (!responseIdentifier || !this.session)
252
- return;
253
- this.session.respond(responseIdentifier, value);
254
- this.clearValidationMessage(responseIdentifier);
255
- this.dispatchPlayerEvent("qti-responsechange", { responseIdentifier, value });
256
- this.emitStateChange();
257
- };
258
- const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
259
- if (interaction.type === "graphicOrder") {
260
- field.append(renderGraphicOrderResponse(interaction, update, currentValue, messages));
261
- return field;
262
- }
263
- if (usesOrderedResponse(interaction)) {
264
- field.append(renderOrderedResponse(interaction, update, currentValue));
265
- return field;
266
- }
267
- if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
268
- field.append(renderGapMatchResponse(interaction, update, currentValue));
269
- return field;
270
- }
271
- if (interaction.type === "graphicAssociate") {
272
- field.append(renderGraphicAssociateResponse(interaction, update, currentValue, messages));
273
- return field;
274
- }
275
- if (interaction.type === "match") {
276
- field.append(renderMatchResponse(interaction, update, currentValue, messages));
277
- return field;
278
- }
279
- if (usesPairResponse(interaction)) {
280
- field.append(renderPairResponse(interaction, update, currentValue, messages));
281
- return field;
282
- }
283
- if (interaction.type === "hotspot" && interaction.object) {
284
- field.append(renderHotspotResponse(interaction, update, currentValue));
285
- return field;
286
- }
287
- if (interaction.type === "hottext") {
288
- field.append(renderHottextResponse(interaction, update, currentValue));
289
- return field;
290
- }
291
- if (usesChoiceSet(interaction)) {
292
- field.append(renderChoice(interaction, update, currentValue));
293
- return field;
294
- }
295
- if (interaction.type === "inlineChoice") {
296
- field.append(renderSelect(interaction, update, currentValue));
297
- return field;
298
- }
299
- if (interaction.type === "extendedText") {
300
- field.append(renderTextResponse(interaction, update, "extended", currentValue));
301
- return field;
302
- }
303
- if (interaction.type === "selectPoint") {
304
- field.append(renderSelectPointResponse(interaction, update, currentValue));
305
- return field;
306
- }
307
- if (interaction.type === "positionObject") {
308
- field.append(renderPositionObjectResponse(interaction, update, currentValue));
309
- return field;
310
- }
311
- if (interaction.type === "drawing") {
312
- field.append(renderDrawingResponse(interaction, update, currentValue));
313
- return field;
314
- }
315
- if (interaction.type === "portableCustom") {
316
- field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
317
- return field;
318
- }
319
- if (interaction.type === "textEntry") {
320
- field.append(renderTextResponse(interaction, update, "entry", currentValue));
321
- return field;
322
- }
323
- if (interaction.type === "slider") {
324
- field.append(renderSliderResponse(interaction, update, currentValue));
325
- return field;
326
- }
327
- if (interaction.type === "upload") {
328
- const input = document.createElement("input");
329
- input.type = "file";
330
- input.setAttribute("aria-label", heading.textContent ?? "Upload response");
331
- input.addEventListener("change", () => update(input.files?.[0]?.name ?? ""));
332
- field.append(input);
333
- return field;
334
- }
335
- if (interaction.type === "endAttempt") {
336
- const button = document.createElement("button");
337
- button.type = "button";
338
- button.textContent = interaction.attributes.title ?? "End attempt";
339
- button.addEventListener("click", () => {
340
- if (responseIdentifier)
341
- update(true);
342
- this.endAttempt();
343
- });
344
- field.append(button);
345
- return field;
346
- }
347
- if (interaction.type === "media") {
348
- field.append(renderObjectAsset(interaction, {
349
- currentValue,
350
- update,
351
- isCompleted: () => this.attemptIsCompleted(),
352
- }));
353
- return field;
354
- }
355
- field.append(renderSelect(interaction, update, currentValue));
356
- return field;
357
- }
358
- renderPortableCustomResponse(interaction, update, currentValue) {
359
- const definition = interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
360
- const responseIdentifier = interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
361
- const currentState = responseIdentifier
362
- ? this.currentInteractionState(responseIdentifier)
363
- : undefined;
364
- const group = document.createElement("div");
365
- group.role = "group";
366
- group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
367
- const host = document.createElement("div");
368
- host.className = "qti3-portable-custom-host";
369
- host.tabIndex = 0;
370
- host.dataset.responseIdentifier = responseIdentifier;
371
- host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
372
- host.dataset.module = definition.module ?? "";
373
- host.dataset.qtiName = interaction.qtiName;
374
- if (definition.interactionModules?.primaryConfiguration) {
375
- host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
376
- }
377
- if (definition.interactionModules?.secondaryConfiguration) {
378
- host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
379
- }
380
- if (currentState !== undefined)
381
- host.dataset.state = JSON.stringify(currentState);
382
- host.setAttribute("role", "application");
383
- host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
384
- host.style.border = "1px solid CanvasText";
385
- host.style.padding = "0.5rem";
386
- host.style.marginBlockEnd = "0.5rem";
387
- if (definition.interactionMarkup.length > 0) {
388
- const markup = document.createElement("div");
389
- markup.className = "qti3-portable-custom-markup";
390
- markup.append(...this.renderContentNodes(definition.interactionMarkup));
391
- host.append(markup);
392
- }
393
- else {
394
- host.textContent = "Portable custom interaction host";
395
- }
396
- const fallback = document.createElement("input");
397
- fallback.type = "hidden";
398
- fallback.className = "qti3-portable-custom-response";
399
- fallback.hidden = true;
400
- fallback.tabIndex = -1;
401
- fallback.setAttribute("aria-hidden", "true");
402
- fallback.value = scalarString(currentValue);
403
- const handlePortableCustomEvent = (event) => {
404
- const state = portableCustomEventState(event);
405
- const value = portableCustomEventValue(event);
406
- const validity = portableCustomEventValidity(event);
407
- if (state !== undefined && responseIdentifier && this.session) {
408
- this.session.setInteractionState(responseIdentifier, state);
409
- host.dataset.state = JSON.stringify(state);
410
- }
411
- if (value !== undefined) {
412
- fallback.value = String(value ?? "");
413
- update(value);
414
- }
415
- if (validity && responseIdentifier) {
416
- this.setPortableCustomValidity(responseIdentifier, validity.valid, validity.message);
417
- this.emitStateChange();
418
- }
419
- if (value === undefined && state !== undefined && !validity)
420
- this.emitStateChange();
421
- };
422
- host.addEventListener("qti3-portable-custom-response", handlePortableCustomEvent);
423
- host.addEventListener("qti3-pci-response", handlePortableCustomEvent);
424
- host.addEventListener("qti3-portable-custom-state", handlePortableCustomEvent);
425
- host.addEventListener("qti3-portable-custom-validity", handlePortableCustomEvent);
426
- queueMicrotask(() => {
427
- this.dispatchPlayerEvent("qti-portable-custom-mount", {
428
- responseIdentifier,
429
- interaction,
430
- definition,
431
- host,
432
- value: currentValue,
433
- state: currentState,
434
- });
435
- });
436
- group.append(host, fallback);
437
- return group;
438
- }
439
- renderEmbeddedInteraction(interaction) {
440
- if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
441
- return this.renderInteraction(interaction);
442
- }
443
- const wrapper = document.createElement("span");
444
- wrapper.className = `qti3-interaction qti3-${interaction.type} qti3-embedded-interaction`;
445
- wrapper.dataset.interactionType = interaction.type;
446
- if (interaction.responseIdentifier)
447
- wrapper.dataset.responseIdentifier = interaction.responseIdentifier;
448
- const responseIdentifier = interaction.responseIdentifier;
449
- const update = (value) => {
450
- if (this.attemptIsCompleted())
451
- return;
452
- if (!responseIdentifier || !this.session)
453
- return;
454
- this.session.respond(responseIdentifier, value);
455
- this.clearValidationMessage(responseIdentifier);
456
- this.dispatchPlayerEvent("qti-responsechange", { responseIdentifier, value });
457
- this.emitStateChange();
458
- };
459
- const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
460
- if (interaction.responseIdentifier) {
461
- wrapper.append(inlineValidationMessageElement(interaction.responseIdentifier));
462
- }
463
- wrapper.append(interaction.type === "inlineChoice"
464
- ? renderSelect(interaction, update, currentValue)
465
- : renderInlineTextEntry(interaction, update, currentValue));
466
- return wrapper;
467
- }
468
- renderContentNodes(nodes) {
469
- return nodes.flatMap((node) => this.renderContentNode(node));
470
- }
471
- renderContentNode(node) {
472
- if (node.kind === "text")
473
- return [document.createTextNode(node.text)];
474
- if (node.kind === "interaction") {
475
- const interaction = this.documentModel?.item.interactions[node.interactionIndex];
476
- return interaction ? [this.renderEmbeddedInteraction(interaction)] : [];
477
- }
478
- if (node.kind === "printedVariable")
479
- return [this.renderPrintedVariable(node.identifier, node.format)];
480
- if (node.kind === "feedback")
481
- return this.renderFeedbackContent(node);
482
- if (node.qtiName === "qti-template-block" || node.qtiName === "qti-template-inline") {
483
- return [this.renderTemplateContent(node)];
484
- }
485
- if (node.qtiName === "qti-position-object-stage") {
486
- return this.renderContentNodes(node.children.filter((child) => !("qtiName" in child) || (child.qtiName !== "object" && child.qtiName !== "img")));
487
- }
488
- if (node.qtiName === "qti-prompt") {
489
- const prompt = document.createElement("p");
490
- copySafeAttributes(prompt, node.attributes);
491
- prompt.classList.add("qti3-item-prompt");
492
- prompt.append(...this.renderContentNodes(node.children));
493
- return [prompt];
494
- }
495
- if (unsafeContentElements.has(node.qtiName))
496
- return [];
497
- const elementName = contentElementName(node.qtiName);
498
- if (!elementName)
499
- return this.renderContentNodes(node.children);
500
- const element = createContentElement(elementName);
501
- copySafeAttributes(element, node.attributes);
502
- const mathTemplateValue = this.mathTemplateValue(node);
503
- if (mathTemplateValue === undefined) {
504
- element.append(...this.renderContentNodes(node.children));
505
- }
506
- else {
507
- element.textContent = mathTemplateValue;
508
- }
509
- return [element];
510
- }
511
- renderTemplateContent(node) {
512
- const element = document.createElement(node.qtiName === "qti-template-block" ? "div" : "span");
513
- copySafeAttributes(element, node.attributes);
514
- element.classList.add(node.qtiName === "qti-template-block" ? "qti3-template-block" : "qti3-template-inline");
515
- element.dataset.templateIdentifier = node.attributes["template-identifier"] ?? "";
516
- element.dataset.templateValueIdentifier = node.attributes.identifier ?? "";
517
- element.dataset.showHide = node.attributes["show-hide"] === "hide" ? "hide" : "show";
518
- element.hidden = !this.isTemplateContentVisible(element);
519
- element.append(...this.renderContentNodes(node.children));
520
- return element;
521
- }
522
- renderPrintedVariable(identifier, format) {
523
- const output = document.createElement("output");
524
- output.className = "qti3-printed-variable";
525
- output.dataset.identifier = identifier;
526
- if (format)
527
- output.dataset.format = format;
528
- output.value = formatPrintedValue(this.currentVariableValue(identifier), format);
529
- output.textContent = output.value;
530
- return output;
531
- }
532
- renderFeedbackContent(node) {
533
- const element = document.createElement(node.feedbackType === "block" ? "section" : "span");
534
- element.className = `qti3-feedback-${node.feedbackType}`;
535
- element.dataset.feedbackIdentifier = node.identifier;
536
- element.dataset.outcomeIdentifier = node.outcomeIdentifier;
537
- element.dataset.showHide = node.showHide;
538
- element.hidden = !this.isFeedbackVisible(node);
539
- element.append(...this.renderContentNodes(node.children));
540
- return [element];
541
- }
542
- updateDynamicBodyState() {
543
- for (const output of this.querySelectorAll(".qti3-printed-variable")) {
544
- const identifier = output.dataset.identifier;
545
- if (!identifier)
546
- continue;
547
- output.value = formatPrintedValue(this.currentVariableValue(identifier), output.dataset.format);
548
- output.textContent = output.value;
549
- }
550
- for (const element of this.querySelectorAll(".qti3-feedback-block, .qti3-feedback-inline")) {
551
- const identifier = element.dataset.feedbackIdentifier;
552
- const outcomeIdentifier = element.dataset.outcomeIdentifier;
553
- if (!identifier || !outcomeIdentifier)
554
- continue;
555
- const value = this.currentVariableValue(outcomeIdentifier);
556
- const hasIdentifier = Array.isArray(value)
557
- ? value.map(String).includes(identifier)
558
- : String(value ?? "") === identifier;
559
- element.hidden = element.dataset.showHide === "hide" ? hasIdentifier : !hasIdentifier;
560
- }
561
- for (const element of this.querySelectorAll(".qti3-template-block, .qti3-template-inline")) {
562
- element.hidden = !this.isTemplateContentVisible(element);
563
- }
564
- }
565
- updateAttemptAvailability() {
566
- const completed = this.attemptIsCompleted();
567
- this.dataset.status = this.session?.serialize().status ?? "unloaded";
568
- const article = this.querySelector(".qti3-player");
569
- if (article)
570
- article.dataset.status = this.dataset.status;
571
- for (const control of this.querySelectorAll(".qti3-interaction button, .qti3-interaction input, .qti3-interaction select, .qti3-interaction textarea")) {
572
- control.disabled = completed;
573
- }
574
- for (const element of this.querySelectorAll(".qti3-interaction [tabindex]:not(button):not(input):not(select):not(textarea)")) {
575
- if (completed) {
576
- element.dataset.previousTabIndex = element.getAttribute("tabindex") ?? "0";
577
- element.tabIndex = -1;
578
- element.setAttribute("aria-disabled", "true");
579
- }
580
- else {
581
- const previous = element.dataset.previousTabIndex;
582
- if (previous !== undefined) {
583
- element.tabIndex = Number(previous);
584
- delete element.dataset.previousTabIndex;
585
- }
586
- element.removeAttribute("aria-disabled");
587
- }
588
- }
589
- }
590
- attemptIsCompleted() {
591
- return this.session?.serialize().status === "completed";
592
- }
593
- isFeedbackVisible(node) {
594
- const value = this.currentVariableValue(node.outcomeIdentifier);
595
- const hasIdentifier = Array.isArray(value)
596
- ? value.map(String).includes(node.identifier)
597
- : String(value ?? "") === node.identifier;
598
- return node.showHide === "show" ? hasIdentifier : !hasIdentifier;
599
- }
600
- isTemplateContentVisible(element) {
601
- const templateIdentifier = element.dataset.templateIdentifier;
602
- const identifier = element.dataset.templateValueIdentifier;
603
- if (!templateIdentifier || !identifier)
604
- return true;
605
- const value = this.currentTemplateValue(templateIdentifier);
606
- const hasIdentifier = Array.isArray(value)
607
- ? value.map(String).includes(identifier)
608
- : String(value ?? "") === identifier;
609
- return element.dataset.showHide === "hide" ? !hasIdentifier : hasIdentifier;
610
- }
611
- currentVariableValue(identifier) {
612
- const state = this.session?.serialize();
613
- return (state?.outcomes[identifier] ??
614
- state?.templateValues?.[identifier] ??
615
- state?.responses[identifier] ??
616
- null);
617
- }
618
- currentTemplateValue(identifier) {
619
- return this.session?.serialize().templateValues?.[identifier] ?? null;
620
- }
621
- mathTemplateValue(node) {
622
- if (node.qtiName !== "mi" && node.qtiName !== "mo")
623
- return undefined;
624
- const identifier = contentNodeText(node).trim();
625
- if (!identifier)
626
- return undefined;
627
- const declaration = this.documentModel?.item.templateDeclarations.find((template) => template.identifier === identifier && template.attributes["math-variable"] === "true");
628
- if (!declaration)
629
- return undefined;
630
- const value = this.currentTemplateValue(identifier);
631
- return value === null ? "" : String(value);
632
- }
633
- currentResponseValue(identifier) {
634
- return this.session?.serialize().responses[identifier] ?? null;
635
- }
636
- currentInteractionState(identifier) {
637
- return this.session?.serialize().interactionStates?.[identifier];
638
- }
639
- setPortableCustomValidity(responseIdentifier, valid, message) {
640
- if (valid) {
641
- this.clearValidationMessage(responseIdentifier);
642
- return;
643
- }
644
- const diagnostic = {
645
- code: "response.portableCustom.validity",
646
- severity: "error",
647
- message: message?.trim() || `${responseIdentifier} is not valid.`,
648
- path: responseIdentifier,
649
- };
650
- this.validationMessages = [
651
- ...this.validationMessages.filter((entry) => entry.path !== responseIdentifier),
652
- diagnostic,
653
- ];
654
- this.renderValidationMessages();
655
- }
656
- applyDefaultStyles() {
657
- this.style.color = "CanvasText";
658
- this.style.backgroundColor = "Canvas";
659
- this.style.colorScheme = "light dark";
660
- }
661
- resolveRenderedAssets(root) {
662
- if (!this.resolveAsset)
663
- return;
664
- for (const element of root.querySelectorAll("[src], [href], [data]")) {
665
- for (const attribute of ["src", "href", "data"]) {
666
- const value = element.getAttribute(attribute);
667
- if (!value || !isResolvableAssetUrl(value))
668
- continue;
669
- element.setAttribute(attribute, this.resolveAsset(value));
670
- }
671
- }
672
- }
673
- validateResponses() {
674
- const state = this.session?.serialize();
675
- if (!state || !this.documentModel)
676
- return [];
677
- const interactionsByResponse = new Map(this.documentModel.item.interactions
678
- .filter((interaction) => interaction.responseIdentifier)
679
- .map((interaction) => [interaction.responseIdentifier, interaction]));
680
- const diagnostics = [];
681
- for (const declaration of this.documentModel.item.responseDeclarations) {
682
- const interaction = interactionsByResponse.get(declaration.identifier);
683
- if (declaration.correctResponse === null && interaction?.type !== "media")
684
- continue;
685
- const minimum = minimumRequiredResponses(interaction);
686
- const count = interaction?.type === "media"
687
- ? mediaPlayCount(state.responses[declaration.identifier] ?? null)
688
- : responseCount(state.responses[declaration.identifier] ?? null);
689
- const maximum = maximumAllowedResponses(interaction);
690
- if (count < minimum) {
691
- diagnostics.push({
692
- code: "response.required",
693
- severity: "error",
694
- message: interaction?.attributes["data-min-selections-message"] ??
695
- (interaction?.type === "media"
696
- ? `${declaration.identifier} requires at least ${minimum} play${minimum === 1 ? "" : "s"}.`
697
- : minimum === 1
698
- ? `${declaration.identifier} requires a response.`
699
- : `${declaration.identifier} requires at least ${minimum} responses.`),
700
- path: declaration.identifier,
701
- });
702
- }
703
- if (maximum !== undefined && count > maximum) {
704
- diagnostics.push({
705
- code: "response.maximum",
706
- severity: "error",
707
- message: interaction?.attributes["data-max-selections-message"] ??
708
- (interaction?.type === "media"
709
- ? `${declaration.identifier} allows at most ${maximum} play${maximum === 1 ? "" : "s"}.`
710
- : `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`),
711
- path: declaration.identifier,
712
- });
713
- }
714
- if (interaction) {
715
- diagnostics.push(...matchMaxDiagnostics(declaration.identifier, interaction, state.responses[declaration.identifier] ?? null));
716
- }
717
- }
718
- return diagnostics;
719
- }
720
- renderValidationMessages() {
721
- const messagesByIdentifier = new Map(this.validationMessages
722
- .filter((message) => message.path)
723
- .map((message) => [message.path, message]));
724
- for (const section of this.querySelectorAll("[data-response-identifier]")) {
725
- const responseIdentifier = section.dataset.responseIdentifier;
726
- if (!responseIdentifier)
727
- continue;
728
- const message = messagesByIdentifier.get(responseIdentifier);
729
- const messageElement = section.querySelector(`[data-validation-for="${responseIdentifier}"]`);
730
- const controls = section.querySelectorAll("input, select, textarea, button");
731
- if (message && messageElement) {
732
- messageElement.textContent = message.message;
733
- messageElement.hidden = false;
734
- for (const control of controls) {
735
- control.setAttribute("aria-invalid", "true");
736
- control.setAttribute("aria-describedby", messageElement.id);
737
- }
738
- }
739
- else if (messageElement) {
740
- messageElement.textContent = "";
741
- messageElement.hidden = true;
742
- for (const control of controls) {
743
- control.removeAttribute("aria-invalid");
744
- control.removeAttribute("aria-describedby");
745
- }
746
- }
747
- }
748
- }
749
- clearValidationMessage(responseIdentifier) {
750
- const before = this.validationMessages.length;
751
- this.validationMessages = this.validationMessages.filter((message) => message.path !== responseIdentifier);
752
- if (this.validationMessages.length !== before)
753
- this.renderValidationMessages();
754
- }
755
- renderFeedback(outcomes) {
756
- const documentModel = this.documentModel;
757
- const feedback = this.querySelector(".qti3-feedback");
758
- if (!documentModel || !feedback)
759
- return;
760
- const visibleFeedback = visibleModalFeedback(documentModel.item, outcomes);
761
- feedback.replaceChildren(...visibleFeedback.map((item) => {
762
- const element = document.createElement("p");
763
- element.dataset.feedbackIdentifier = item.identifier;
764
- element.textContent = item.text;
765
- return element;
766
- }));
767
- feedback.hidden = visibleFeedback.length === 0;
768
- }
769
- }
770
- export function defineQtiAssessmentItemPlayer() {
771
- if (globalThis.customElements && !customElements.get("qti-assessment-item-player")) {
772
- customElements.define("qti-assessment-item-player", QtiAssessmentItemPlayer);
773
- }
774
- }
775
- const defaultEnglishPlayerMessages = {
776
- remove: () => "Remove",
777
- removePair: ({ label }) => `Remove ${label}`,
778
- };
779
- const playerMessages = {
780
- defaultEnglish: defaultEnglishPlayerMessages,
781
- spanish: playerMessageCatalog("Quitar", ({ label }) => `Quitar ${label}`),
782
- swedish: playerMessageCatalog("Ta bort", ({ label }) => `Ta bort ${label}`),
783
- german: playerMessageCatalog("Entfernen", ({ label }) => `${label} entfernen`),
784
- portuguese: playerMessageCatalog("Remover", ({ label }) => `Remover ${label}`),
785
- french: playerMessageCatalog("Supprimer", ({ label }) => `Supprimer ${label}`),
786
- };
787
- const builtInPlayerMessageCatalogs = new Map([
788
- ["en", playerMessages.defaultEnglish],
789
- ["es", playerMessages.spanish],
790
- ["es-es", playerMessages.spanish],
791
- ["es-mx", playerMessages.spanish],
792
- ["sv", playerMessages.swedish],
793
- ["sv-se", playerMessages.swedish],
794
- ["de", playerMessages.german],
795
- ["de-de", playerMessages.german],
796
- ["pt", playerMessages.portuguese],
797
- ["pt-br", playerMessages.portuguese],
798
- ["pt-pt", playerMessages.portuguese],
799
- ["fr", playerMessages.french],
800
- ["fr-ca", playerMessages.french],
801
- ["fr-fr", playerMessages.french],
802
- ]);
803
- function playerMessageCatalog(remove, removePair) {
804
- return {
805
- remove: () => remove,
806
- removePair,
807
- };
808
- }
809
- function resolvePlayerMessages(locale, overrides) {
810
- const catalog = builtInPlayerMessageCatalog(locale);
811
- return {
812
- remove: overrides.remove ?? catalog?.remove ?? defaultEnglishPlayerMessages.remove,
813
- removePair: overrides.removePair ?? catalog?.removePair ?? defaultEnglishPlayerMessages.removePair,
814
- };
815
- }
816
- function builtInPlayerMessageCatalog(locale) {
817
- for (const candidate of localeFallbacks(locale)) {
818
- const catalog = builtInPlayerMessageCatalogs.get(candidate);
819
- if (catalog)
820
- return catalog;
821
- }
822
- return undefined;
823
- }
824
- function localeFallbacks(locale) {
825
- const normalized = normalizedLocale(locale)?.toLowerCase();
826
- if (!normalized)
827
- return ["en"];
828
- const parts = normalized.split("-");
829
- const fallbacks = [];
830
- for (let length = parts.length; length > 0; length -= 1) {
831
- fallbacks.push(parts.slice(0, length).join("-"));
832
- }
833
- return fallbacks.includes("en") ? fallbacks : [...fallbacks, "en"];
834
- }
835
- function normalizedLocale(value) {
836
- const trimmed = value?.trim();
837
- if (!trimmed)
838
- return undefined;
839
- try {
840
- return Intl.getCanonicalLocales(trimmed)[0] ?? trimmed;
841
- }
842
- catch {
843
- return trimmed;
844
- }
845
- }
846
- function defaultPlayerLocale(host) {
847
- const elementLanguage = normalizedLocale(host?.getAttribute("lang"));
848
- if (elementLanguage)
849
- return elementLanguage;
850
- const navigatorLanguages = globalThis.navigator?.languages ?? [];
851
- for (const language of navigatorLanguages) {
852
- const normalized = normalizedLocale(language);
853
- if (normalized)
854
- return normalized;
855
- }
856
- return (normalizedLocale(globalThis.navigator?.language) ??
857
- normalizedLocale(host?.closest("[lang]")?.getAttribute("lang")) ??
858
- normalizedLocale(host?.ownerDocument?.documentElement.lang) ??
859
- normalizedLocale(globalThis.document?.documentElement.lang) ??
860
- "en");
861
- }
862
- function removeButton(label, messages) {
863
- const safeLabel = label?.trim() || messages.remove();
864
- const button = document.createElement("button");
865
- button.type = "button";
866
- button.className = "qti3-icon-button qti3-remove-button";
867
- button.title = messages.remove();
868
- button.setAttribute("aria-label", messages.removePair({ label: safeLabel }));
869
- button.append(trashIcon());
870
- return button;
871
- }
872
- function inlineIcon(className, paths) {
873
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
874
- svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
875
- svg.setAttribute("width", "24");
876
- svg.setAttribute("height", "24");
877
- svg.setAttribute("viewBox", "0 0 24 24");
878
- svg.setAttribute("fill", "none");
879
- svg.setAttribute("stroke", "currentColor");
880
- svg.setAttribute("stroke-width", "2");
881
- svg.setAttribute("stroke-linecap", "round");
882
- svg.setAttribute("stroke-linejoin", "round");
883
- svg.setAttribute("aria-hidden", "true");
884
- svg.setAttribute("focusable", "false");
885
- svg.setAttribute("class", className);
886
- for (const entry of paths) {
887
- const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
888
- if (typeof entry === "string") {
889
- path.setAttribute("d", entry);
890
- }
891
- else {
892
- path.setAttribute("d", entry.d);
893
- if (entry.stroke) {
894
- path.setAttribute("stroke", entry.stroke);
895
- path.style.stroke = entry.stroke;
896
- }
897
- if (entry.fill) {
898
- path.setAttribute("fill", entry.fill);
899
- path.style.fill = entry.fill;
900
- }
901
- }
902
- svg.append(path);
903
- }
904
- return svg;
905
- }
906
- function renderChoice(interaction, update, currentValue) {
907
- const group = responseGroup("qti3-choice-group");
908
- const multiple = interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
909
- const selected = new Set(valueToStrings(currentValue));
910
- const list = document.createElement("div");
911
- list.className = "qti3-choice-list";
912
- list.role = "group";
913
- list.setAttribute("aria-label", `${readableType(interaction.type)} options`);
914
- const syncSelected = () => {
915
- for (const label of list.querySelectorAll(".qti3-choice-option")) {
916
- const identifier = label.dataset.choiceIdentifier ?? "";
917
- label.dataset.selected = selected.has(identifier) ? "true" : "false";
918
- }
919
- };
920
- for (const [index, choice] of choicesOrFallback(interaction).entries()) {
921
- const label = document.createElement("label");
922
- label.className = "qti3-choice-option";
923
- label.dataset.choiceIdentifier = choice.identifier;
924
- const input = document.createElement("input");
925
- input.type = multiple ? "checkbox" : "radio";
926
- input.name = interaction.responseIdentifier ?? interaction.type;
927
- input.value = choice.identifier;
928
- input.checked = selected.has(choice.identifier);
929
- input.addEventListener("change", () => {
930
- if (multiple) {
931
- if (input.checked)
932
- selected.add(choice.identifier);
933
- else
934
- selected.delete(choice.identifier);
935
- update([...selected]);
936
- }
937
- else {
938
- selected.clear();
939
- selected.add(choice.identifier);
940
- syncSelected();
941
- update(input.value);
942
- }
943
- syncSelected();
944
- });
945
- const visibleLabel = choicePresentationLabel(interaction, index);
946
- const optionParts = [input];
947
- if (visibleLabel) {
948
- const labelText = document.createElement("span");
949
- labelText.className = "qti3-choice-label";
950
- labelText.textContent = visibleLabel;
951
- optionParts.push(labelText);
952
- }
953
- const text = document.createElement("span");
954
- text.className = "qti3-choice-text";
955
- text.textContent = choice.text;
956
- optionParts.push(text);
957
- label.append(...optionParts);
958
- list.append(label);
959
- }
960
- syncSelected();
961
- group.append(list);
962
- return group;
963
- }
964
- function responseGroup(className) {
965
- const group = document.createElement("div");
966
- group.className = ["qti3-response-group", className].filter(Boolean).join(" ");
967
- return group;
968
- }
969
- function trashIcon() {
970
- return inlineIcon("qti3-trash-icon", [
971
- { d: "M0 0h24v24H0z", stroke: "none", fill: "none" },
972
- "M4 7l16 0",
973
- "M10 11l0 6",
974
- "M14 11l0 6",
975
- "M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",
976
- "M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",
977
- ]);
978
- }
979
- const movementIconPaths = {
980
- up: ["M12 5l0 14", "M18 11l-6 -6", "M6 11l6 -6"],
981
- down: ["M12 5l0 14", "M18 13l-6 6", "M6 13l6 6"],
982
- left: ["M5 12l14 0", "M5 12l6 6", "M5 12l6 -6"],
983
- right: ["M5 12l14 0", "M13 18l6 -6", "M13 6l6 6"],
984
- };
985
- function movementIcon(direction) {
986
- return inlineIcon("qti3-movement-icon", movementIconPaths[direction]);
987
- }
988
- function movementButton(direction, accessibleName, onClick) {
989
- const button = document.createElement("button");
990
- button.type = "button";
991
- button.className = "qti3-icon-button qti3-move-button";
992
- button.dataset.moveDirection = direction;
993
- button.setAttribute("aria-label", accessibleName);
994
- button.append(movementIcon(direction));
995
- button.addEventListener("click", onClick);
996
- return button;
997
- }
998
- function movementLabel(target, direction) {
999
- return `Move ${target} ${direction}`;
1000
- }
1001
- function renderHottextResponse(interaction, update, currentValue) {
1002
- const group = document.createElement("div");
1003
- group.className = "qti3-hottext-group";
1004
- group.role = "group";
1005
- group.setAttribute("aria-label", "Hottext options");
1006
- const selected = new Set(valueToStrings(currentValue));
1007
- const multiple = interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
1008
- const passage = document.createElement("p");
1009
- passage.className = "qti3-hottext-passage";
1010
- const syncSelected = () => {
1011
- for (const button of passage.querySelectorAll(".qti3-hottext-token")) {
1012
- const identifier = button.dataset.choiceIdentifier ?? "";
1013
- const isSelected = selected.has(identifier);
1014
- button.dataset.selected = isSelected ? "true" : "false";
1015
- button.setAttribute("aria-pressed", String(isSelected));
1016
- }
1017
- };
1018
- const segments = interaction.hottextSegments && interaction.hottextSegments.length > 0
1019
- ? interaction.hottextSegments
1020
- : choicesOrFallback(interaction).map((choice) => ({
1021
- kind: "hottext",
1022
- identifier: choice.identifier,
1023
- text: choice.text,
1024
- attributes: choice.attributes,
1025
- source: choice.source,
1026
- }));
1027
- const content = [];
1028
- for (const [segmentIndex, segment] of segments.entries()) {
1029
- if (segment.kind === "text") {
1030
- content.push(document.createTextNode(normalizeInlineSegmentText(segment.text)));
1031
- continue;
1032
- }
1033
- const button = document.createElement("button");
1034
- button.type = "button";
1035
- button.className = "qti3-hottext-token";
1036
- button.dataset.choiceIdentifier = segment.identifier;
1037
- button.textContent = segment.text;
1038
- button.addEventListener("click", () => {
1039
- if (multiple) {
1040
- if (selected.has(segment.identifier))
1041
- selected.delete(segment.identifier);
1042
- else
1043
- selected.add(segment.identifier);
1044
- update([...selected]);
1045
- }
1046
- else {
1047
- selected.clear();
1048
- selected.add(segment.identifier);
1049
- update(segment.identifier);
1050
- }
1051
- syncSelected();
1052
- });
1053
- appendInlineControl(content, button, segments[segmentIndex + 1]);
1054
- }
1055
- passage.append(...content);
1056
- syncSelected();
1057
- group.append(passage);
1058
- return group;
1059
- }
1060
- function choicePresentationLabel(interaction, index) {
1061
- const classNames = new Set((interaction.attributes.class ?? "").split(/\s+/).filter(Boolean));
1062
- if (classNames.has("qti-labels-none"))
1063
- return "";
1064
- const labels = classNames.has("qti-labels-decimal")
1065
- ? Array.from({ length: 26 }, (_, item) => `${item + 1}`)
1066
- : classNames.has("qti-labels-lower-alpha")
1067
- ? "abcdefghijklmnopqrstuvwxyz".split("")
1068
- : "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
1069
- const suffix = classNames.has("qti-labels-suffix-none")
1070
- ? ""
1071
- : classNames.has("qti-labels-suffix-parenthesis")
1072
- ? ")"
1073
- : ".";
1074
- return `${labels[index] ?? `${index + 1}`}${suffix}`;
1075
- }
1076
- function usesChoiceSet(interaction) {
1077
- if (interaction.type === "choice" || interaction.type === "hotspot") {
1078
- return true;
1079
- }
1080
- return (interaction.responseCardinality === "multiple" && interaction.responseBaseType === "identifier");
1081
- }
1082
- function usesOrderedResponse(interaction) {
1083
- return (interaction.responseCardinality === "ordered" ||
1084
- interaction.type === "order" ||
1085
- interaction.type === "graphicOrder");
1086
- }
1087
- function usesPairResponse(interaction) {
1088
- return (interaction.responseBaseType === "pair" ||
1089
- interaction.responseBaseType === "directedPair" ||
1090
- interaction.type === "associate" ||
1091
- interaction.type === "graphicAssociate" ||
1092
- interaction.type === "match" ||
1093
- interaction.type === "gapMatch" ||
1094
- interaction.type === "graphicGapMatch");
1095
- }
1096
- function renderOrderedResponse(interaction, update, currentValue) {
1097
- const group = responseGroup();
1098
- appendGraphicContext(group, interaction);
1099
- const choices = choicesOrFallback(interaction).filter((choice) => choice.role !== "gap");
1100
- const ordered = orderChoicesFromValue(choices, currentValue);
1101
- const list = document.createElement("ol");
1102
- list.className = "qti3-reorder-list";
1103
- list.setAttribute("aria-label", `${readableType(interaction.type)} current order`);
1104
- let draggedIdentifier;
1105
- let pointerDraggedIdentifier;
1106
- const commit = () => update(ordered.map((choice) => choice.identifier));
1107
- const moveChoice = (from, to) => {
1108
- if (from === to || from < 0 || from >= ordered.length || to < 0 || to >= ordered.length)
1109
- return;
1110
- const [choice] = ordered.splice(from, 1);
1111
- if (!choice)
1112
- return;
1113
- ordered.splice(to, 0, choice);
1114
- renderList();
1115
- commit();
1116
- list
1117
- .querySelector(`[data-choice-identifier="${choice.identifier}"]`)
1118
- ?.focus();
1119
- };
1120
- const renderList = () => {
1121
- list.replaceChildren(...ordered.map((choice, index) => {
1122
- const item = document.createElement("li");
1123
- item.className = "qti3-reorder-item";
1124
- item.draggable = true;
1125
- item.dataset.choiceIdentifier = choice.identifier;
1126
- item.addEventListener("pointerdown", (event) => {
1127
- if (event.button !== 0 || event.target.closest("button"))
1128
- return;
1129
- pointerDraggedIdentifier = choice.identifier;
1130
- try {
1131
- item.setPointerCapture(event.pointerId);
1132
- }
1133
- catch {
1134
- // Synthetic pointer events and some browser drag paths do not create a capturable pointer.
1135
- }
1136
- });
1137
- item.addEventListener("pointerup", (event) => {
1138
- if (!pointerDraggedIdentifier)
1139
- return;
1140
- const target = document
1141
- .elementFromPoint(event.clientX, event.clientY)
1142
- ?.closest(".qti3-reorder-item");
1143
- const targetIdentifier = target?.dataset.choiceIdentifier;
1144
- pointerDraggedIdentifier = undefined;
1145
- if (!targetIdentifier)
1146
- return;
1147
- const from = ordered.findIndex((entry) => entry.identifier === choice.identifier);
1148
- const to = ordered.findIndex((entry) => entry.identifier === targetIdentifier);
1149
- moveChoice(from, to);
1150
- });
1151
- item.addEventListener("pointercancel", () => {
1152
- pointerDraggedIdentifier = undefined;
1153
- });
1154
- item.addEventListener("dragstart", (event) => {
1155
- draggedIdentifier = choice.identifier;
1156
- event.dataTransfer?.setData("text/plain", choice.identifier);
1157
- event.dataTransfer?.setDragImage(item, 12, 12);
1158
- });
1159
- item.addEventListener("dragover", (event) => {
1160
- event.preventDefault();
1161
- item.classList.add("qti3-drop-target");
1162
- });
1163
- item.addEventListener("dragleave", () => item.classList.remove("qti3-drop-target"));
1164
- item.addEventListener("drop", (event) => {
1165
- event.preventDefault();
1166
- item.classList.remove("qti3-drop-target");
1167
- const dragged = event.dataTransfer?.getData("text/plain") || draggedIdentifier;
1168
- const from = ordered.findIndex((entry) => entry.identifier === dragged);
1169
- moveChoice(from, index);
1170
- });
1171
- const handle = document.createElement("button");
1172
- handle.type = "button";
1173
- handle.className = "qti3-token qti3-reorder-handle";
1174
- handle.dataset.choiceIdentifier = choice.identifier;
1175
- handle.setAttribute("aria-label", `${choice.text}, position ${index + 1} of ${ordered.length}`);
1176
- handle.textContent = choice.text;
1177
- handle.addEventListener("keydown", (event) => {
1178
- if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
1179
- event.preventDefault();
1180
- moveChoice(index, index - 1);
1181
- }
1182
- else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
1183
- event.preventDefault();
1184
- moveChoice(index, index + 1);
1185
- }
1186
- });
1187
- const up = movementButton("up", movementLabel(choice.text, "up"), () => moveChoice(index, index - 1));
1188
- up.disabled = index === 0;
1189
- const down = movementButton("down", movementLabel(choice.text, "down"), () => moveChoice(index, index + 1));
1190
- down.disabled = index === ordered.length - 1;
1191
- item.append(handle, up, down);
1192
- return item;
1193
- }));
1194
- };
1195
- renderList();
1196
- group.append(list);
1197
- return group;
1198
- }
1199
- function renderPairResponse(interaction, update, currentValue, messages) {
1200
- const group = responseGroup();
1201
- appendGraphicContext(group, interaction);
1202
- const sources = sourceChoices(interaction);
1203
- const targets = targetChoices(interaction);
1204
- const selectedPairs = valueToStrings(currentValue);
1205
- let selectedSource;
1206
- let selectedTarget;
1207
- const labels = pairRegionLabels(interaction);
1208
- const sourceRegion = tokenRegion(`${readableType(interaction.type)} sources`, labels.source);
1209
- const targetRegion = tokenRegion(`${readableType(interaction.type)} targets`, labels.target);
1210
- const selector = document.createElement("div");
1211
- selector.className = "qti3-pair-selector";
1212
- const pairList = document.createElement("ul");
1213
- pairList.className = "qti3-pair-list";
1214
- pairList.setAttribute("aria-label", `${readableType(interaction.type)} selected pairs`);
1215
- let draggedSource;
1216
- const commit = () => {
1217
- if (interaction.responseCardinality === "single")
1218
- update(selectedPairs[0] ?? null);
1219
- else
1220
- update([...selectedPairs]);
1221
- };
1222
- const syncPressed = () => {
1223
- for (const button of sourceRegion.querySelectorAll("button")) {
1224
- button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false");
1225
- }
1226
- for (const button of targetRegion.querySelectorAll("button")) {
1227
- button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedTarget?.identifier ? "true" : "false");
1228
- }
1229
- };
1230
- const addSelectedPair = () => {
1231
- if (!selectedSource || !selectedTarget)
1232
- return;
1233
- const pair = `${selectedSource.identifier} ${selectedTarget.identifier}`;
1234
- if (!selectedPairs.includes(pair))
1235
- selectedPairs.push(pair);
1236
- selectedSource = undefined;
1237
- selectedTarget = undefined;
1238
- syncPressed();
1239
- renderPairs();
1240
- commit();
1241
- };
1242
- const addPair = (sourceIdentifier, targetIdentifier) => {
1243
- const source = sources.find((choice) => choice.identifier === sourceIdentifier);
1244
- const target = targets.find((choice) => choice.identifier === targetIdentifier);
1245
- if (!source || !target)
1246
- return;
1247
- selectedSource = source;
1248
- selectedTarget = target;
1249
- addSelectedPair();
1250
- };
1251
- const renderPairs = () => {
1252
- pairList.replaceChildren(...selectedPairs.map((pair) => {
1253
- const [source, target] = pair.split(" ");
1254
- const item = document.createElement("li");
1255
- item.className = "qti3-pair-chip";
1256
- const text = document.createElement("span");
1257
- text.textContent = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
1258
- const remove = removeButton(text.textContent, messages);
1259
- remove.addEventListener("click", () => {
1260
- const index = selectedPairs.indexOf(pair);
1261
- if (index >= 0)
1262
- selectedPairs.splice(index, 1);
1263
- renderPairs();
1264
- commit();
1265
- });
1266
- item.append(text, remove);
1267
- return item;
1268
- }));
1269
- };
1270
- for (const choice of sources) {
1271
- const button = tokenButton(choice);
1272
- button.draggable = true;
1273
- button.addEventListener("dragstart", (event) => {
1274
- draggedSource = choice.identifier;
1275
- event.dataTransfer?.setData("text/plain", choice.identifier);
1276
- event.dataTransfer?.setDragImage(button, 8, 8);
1277
- });
1278
- button.addEventListener("dragend", () => {
1279
- draggedSource = undefined;
1280
- syncPressed();
1281
- });
1282
- button.addEventListener("click", () => {
1283
- selectedSource = choice;
1284
- syncPressed();
1285
- addSelectedPair();
1286
- });
1287
- sourceRegion.append(button);
1288
- }
1289
- for (const choice of targets) {
1290
- const button = tokenButton(choice);
1291
- button.addEventListener("dragover", (event) => {
1292
- event.preventDefault();
1293
- button.classList.add("qti3-drop-target");
1294
- });
1295
- button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
1296
- button.addEventListener("drop", (event) => {
1297
- event.preventDefault();
1298
- button.classList.remove("qti3-drop-target");
1299
- addPair(event.dataTransfer?.getData("text/plain") || draggedSource, choice.identifier);
1300
- });
1301
- button.addEventListener("click", () => {
1302
- selectedTarget = choice;
1303
- syncPressed();
1304
- addSelectedPair();
1305
- });
1306
- targetRegion.append(button);
1307
- }
1308
- selector.append(sourceRegion, targetRegion);
1309
- renderPairs();
1310
- group.append(selector, pairList);
1311
- return group;
1312
- }
1313
- function renderMatchResponse(interaction, update, currentValue, messages) {
1314
- const group = responseGroup();
1315
- const sources = sourceChoices(interaction);
1316
- const targets = targetChoices(interaction);
1317
- const selectedPairs = valueToStrings(currentValue);
1318
- let selectedSource;
1319
- let selectedTarget;
1320
- let draggedSource;
1321
- const selector = document.createElement("div");
1322
- selector.className = "qti3-match-selector";
1323
- const sourceRegion = tokenRegion("Match sources");
1324
- sourceRegion.classList.add("qti3-match-source-bank");
1325
- const targetRegion = tokenRegion("Match targets");
1326
- targetRegion.classList.add("qti3-match-target-bank");
1327
- const pairList = document.createElement("ul");
1328
- pairList.className = "qti3-pair-list";
1329
- pairList.setAttribute("aria-label", "Match selected pairs");
1330
- const commit = () => {
1331
- if (interaction.responseCardinality === "single")
1332
- update(selectedPairs[0] ?? null);
1333
- else
1334
- update([...selectedPairs]);
1335
- };
1336
- const removePair = (pair) => {
1337
- const index = selectedPairs.indexOf(pair);
1338
- if (index >= 0)
1339
- selectedPairs.splice(index, 1);
1340
- };
1341
- const syncPressed = () => {
1342
- for (const button of sourceRegion.querySelectorAll("button")) {
1343
- const identifier = button.dataset.choiceIdentifier ?? "";
1344
- button.setAttribute("aria-pressed", identifier === selectedSource?.identifier ||
1345
- selectedPairs.some((pair) => pair.startsWith(`${identifier} `))
1346
- ? "true"
1347
- : "false");
1348
- }
1349
- for (const button of targetRegion.querySelectorAll("button")) {
1350
- const identifier = button.dataset.choiceIdentifier ?? "";
1351
- button.setAttribute("aria-pressed", identifier === selectedTarget?.identifier ||
1352
- selectedPairs.some((pair) => pair.endsWith(` ${identifier}`))
1353
- ? "true"
1354
- : "false");
1355
- }
1356
- };
1357
- const renderPairs = () => {
1358
- pairList.replaceChildren(...selectedPairs.map((pair) => {
1359
- const [source, target] = pair.split(" ");
1360
- const label = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
1361
- const item = document.createElement("li");
1362
- item.className = "qti3-pair-chip";
1363
- const text = document.createElement("span");
1364
- text.textContent = label;
1365
- const remove = removeButton(label, messages);
1366
- remove.addEventListener("click", () => {
1367
- removePair(pair);
1368
- syncPressed();
1369
- renderPairs();
1370
- commit();
1371
- });
1372
- item.append(text, remove);
1373
- return item;
1374
- }));
1375
- };
1376
- const clearSelection = () => {
1377
- selectedSource = undefined;
1378
- selectedTarget = undefined;
1379
- };
1380
- const removePairsForSource = (source) => {
1381
- for (const existing of selectedPairs.filter((pair) => pair.startsWith(`${source.identifier} `))) {
1382
- removePair(existing);
1383
- }
1384
- };
1385
- const removePairsForTarget = (target) => {
1386
- for (const existing of selectedPairs.filter((pair) => pair.endsWith(` ${target.identifier}`))) {
1387
- removePair(existing);
1388
- }
1389
- };
1390
- const togglePair = (source, target) => {
1391
- const pair = `${source.identifier} ${target.identifier}`;
1392
- if (selectedPairs.includes(pair)) {
1393
- removePair(pair);
1394
- }
1395
- else {
1396
- if (interaction.responseCardinality === "single")
1397
- selectedPairs.splice(0);
1398
- if (parseUnlimitedMaximum(source.attributes["match-max"]) === 1) {
1399
- removePairsForSource(source);
1400
- }
1401
- if (parseUnlimitedMaximum(target.attributes["match-max"]) === 1) {
1402
- removePairsForTarget(target);
1403
- }
1404
- selectedPairs.push(pair);
1405
- }
1406
- clearSelection();
1407
- syncPressed();
1408
- renderPairs();
1409
- commit();
1410
- };
1411
- const addSelectedPair = () => {
1412
- if (!selectedSource || !selectedTarget)
1413
- return;
1414
- togglePair(selectedSource, selectedTarget);
1415
- };
1416
- const addPair = (sourceIdentifier, targetIdentifier) => {
1417
- const source = sources.find((choice) => choice.identifier === sourceIdentifier);
1418
- const target = targets.find((choice) => choice.identifier === targetIdentifier);
1419
- if (!source || !target)
1420
- return;
1421
- togglePair(source, target);
1422
- };
1423
- for (const source of sources) {
1424
- const button = tokenButton(source);
1425
- button.classList.add("qti3-match-source");
1426
- button.draggable = true;
1427
- button.addEventListener("dragstart", (event) => {
1428
- draggedSource = source.identifier;
1429
- event.dataTransfer?.setData("text/plain", source.identifier);
1430
- event.dataTransfer?.setDragImage(button, 8, 8);
1431
- });
1432
- button.addEventListener("dragend", () => {
1433
- draggedSource = undefined;
1434
- syncPressed();
1435
- });
1436
- button.addEventListener("click", () => {
1437
- selectedSource = source;
1438
- syncPressed();
1439
- addSelectedPair();
1440
- });
1441
- button.addEventListener("keydown", (event) => {
1442
- if (event.key !== "Delete" && event.key !== "Backspace")
1443
- return;
1444
- event.preventDefault();
1445
- removePairsForSource(source);
1446
- clearSelection();
1447
- syncPressed();
1448
- renderPairs();
1449
- commit();
1450
- });
1451
- sourceRegion.append(button);
1452
- }
1453
- for (const target of targets) {
1454
- const button = tokenButton(target);
1455
- button.classList.add("qti3-match-target");
1456
- button.addEventListener("dragover", (event) => {
1457
- event.preventDefault();
1458
- button.classList.add("qti3-drop-target");
1459
- });
1460
- button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
1461
- button.addEventListener("drop", (event) => {
1462
- event.preventDefault();
1463
- button.classList.remove("qti3-drop-target");
1464
- addPair(event.dataTransfer?.getData("text/plain") || draggedSource, target.identifier);
1465
- });
1466
- button.addEventListener("click", () => {
1467
- selectedTarget = target;
1468
- syncPressed();
1469
- addSelectedPair();
1470
- });
1471
- button.addEventListener("keydown", (event) => {
1472
- if (event.key !== "Delete" && event.key !== "Backspace")
1473
- return;
1474
- event.preventDefault();
1475
- removePairsForTarget(target);
1476
- clearSelection();
1477
- syncPressed();
1478
- renderPairs();
1479
- commit();
1480
- });
1481
- targetRegion.append(button);
1482
- }
1483
- selector.append(sourceRegion, targetRegion);
1484
- syncPressed();
1485
- renderPairs();
1486
- group.append(selector, pairList);
1487
- return group;
1488
- }
1489
- function pairRegionLabels(interaction) {
1490
- if (interaction.type === "associate")
1491
- return { source: "First concept", target: "Pair with" };
1492
- if (interaction.type === "match")
1493
- return { source: "Prompt", target: "Match" };
1494
- return { source: "Source", target: "Target" };
1495
- }
1496
- function renderGraphicOrderResponse(interaction, update, currentValue, messages) {
1497
- const group = responseGroup();
1498
- const width = objectWidth(interaction);
1499
- const height = objectHeight(interaction);
1500
- const choices = choicesOrFallback(interaction).filter((choice) => choice.role === "hotspot");
1501
- const orderedIdentifiers = valueToStrings(currentValue).filter((identifier) => choices.some((choice) => choice.identifier === identifier));
1502
- const surface = document.createElement("div");
1503
- surface.className = "qti3-graphic-order-surface";
1504
- surface.role = "group";
1505
- surface.setAttribute("aria-label", `${readableType(interaction.type)} hotspots`);
1506
- surface.style.position = "relative";
1507
- surface.style.inlineSize = `${width}px`;
1508
- surface.style.aspectRatio = `${width} / ${height}`;
1509
- surface.style.maxInlineSize = "100%";
1510
- surface.style.border = "1px solid CanvasText";
1511
- surface.style.background = "Canvas";
1512
- surface.style.overflow = "hidden";
1513
- const object = interaction.object;
1514
- if (object?.data && objectIsImage(object)) {
1515
- const image = document.createElement("img");
1516
- image.src = object.data;
1517
- image.alt = object.text || `${readableType(interaction.type)} image`;
1518
- image.style.inlineSize = "100%";
1519
- image.style.blockSize = "100%";
1520
- image.style.objectFit = "contain";
1521
- surface.append(image);
1522
- }
1523
- const sequenceLines = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1524
- sequenceLines.classList.add("qti3-graphic-sequence-lines");
1525
- sequenceLines.setAttribute("viewBox", `0 0 ${width} ${height}`);
1526
- sequenceLines.setAttribute("aria-hidden", "true");
1527
- const markerId = `qti3-arrow-${Math.random().toString(36).slice(2)}`;
1528
- const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
1529
- const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
1530
- marker.setAttribute("id", markerId);
1531
- marker.setAttribute("viewBox", "0 0 10 10");
1532
- marker.setAttribute("refX", "8");
1533
- marker.setAttribute("refY", "5");
1534
- marker.setAttribute("markerWidth", "5");
1535
- marker.setAttribute("markerHeight", "5");
1536
- marker.setAttribute("orient", "auto-start-reverse");
1537
- const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path");
1538
- arrow.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
1539
- marker.append(arrow);
1540
- defs.append(marker);
1541
- sequenceLines.append(defs);
1542
- surface.append(sequenceLines);
1543
- const summary = document.createElement("p");
1544
- summary.className = "qti3-selection-summary";
1545
- summary.setAttribute("aria-live", "polite");
1546
- const list = document.createElement("ol");
1547
- list.className = "qti3-graphic-order-list";
1548
- list.setAttribute("aria-label", `${readableType(interaction.type)} selected order`);
1549
- const orderedChoices = () => orderedIdentifiers
1550
- .map((identifier) => choices.find((choice) => choice.identifier === identifier))
1551
- .filter((choice) => Boolean(choice));
1552
- const commit = () => update([...orderedIdentifiers]);
1553
- const focusHotspot = (identifier) => {
1554
- surface.querySelector(`[data-choice-identifier="${identifier}"]`)?.focus();
1555
- };
1556
- const focusRelativeHotspot = (choice, delta) => {
1557
- const index = choices.findIndex((entry) => entry.identifier === choice.identifier);
1558
- const next = choices[(index + delta + choices.length) % choices.length];
1559
- if (next)
1560
- focusHotspot(next.identifier);
1561
- };
1562
- const chooseHotspot = (choice) => {
1563
- const existingIndex = orderedIdentifiers.indexOf(choice.identifier);
1564
- if (existingIndex >= 0)
1565
- orderedIdentifiers.splice(existingIndex, 1);
1566
- orderedIdentifiers.push(choice.identifier);
1567
- renderState();
1568
- commit();
1569
- focusHotspot(choice.identifier);
1570
- };
1571
- const removeHotspot = (identifier) => {
1572
- const index = orderedIdentifiers.indexOf(identifier);
1573
- if (index < 0)
1574
- return;
1575
- orderedIdentifiers.splice(index, 1);
1576
- renderState();
1577
- commit();
1578
- focusHotspot(identifier);
1579
- };
1580
- const moveHotspot = (identifier, delta) => {
1581
- const index = orderedIdentifiers.indexOf(identifier);
1582
- const targetIndex = index + delta;
1583
- if (index < 0 || targetIndex < 0 || targetIndex >= orderedIdentifiers.length)
1584
- return;
1585
- const [entry] = orderedIdentifiers.splice(index, 1);
1586
- if (!entry)
1587
- return;
1588
- orderedIdentifiers.splice(targetIndex, 0, entry);
1589
- renderState();
1590
- commit();
1591
- list.querySelector(`[data-choice-identifier="${identifier}"]`)?.focus();
1592
- };
1593
- const renderState = () => {
1594
- for (const line of sequenceLines.querySelectorAll("line"))
1595
- line.remove();
1596
- const currentChoices = orderedChoices();
1597
- for (let index = 0; index < currentChoices.length - 1; index += 1) {
1598
- const current = currentChoices[index];
1599
- const next = currentChoices[index + 1];
1600
- if (!current || !next)
1601
- continue;
1602
- const start = hotspotCenter(current, width, height);
1603
- const end = hotspotCenter(next, width, height);
1604
- const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
1605
- line.setAttribute("x1", String(start.x));
1606
- line.setAttribute("y1", String(start.y));
1607
- line.setAttribute("x2", String(end.x));
1608
- line.setAttribute("y2", String(end.y));
1609
- line.setAttribute("marker-end", `url(#${markerId})`);
1610
- sequenceLines.append(line);
1611
- }
1612
- for (const button of surface.querySelectorAll(".qti3-hotspot-button")) {
1613
- const identifier = button.dataset.choiceIdentifier ?? "";
1614
- const index = orderedIdentifiers.indexOf(identifier);
1615
- const isSelected = index >= 0;
1616
- button.dataset.selected = isSelected ? "true" : "false";
1617
- button.setAttribute("aria-pressed", isSelected ? "true" : "false");
1618
- button.dataset.order = isSelected ? String(index + 1) : "";
1619
- const badge = button.querySelector(".qti3-graphic-order-number");
1620
- if (badge)
1621
- badge.textContent = isSelected ? String(index + 1) : "";
1622
- }
1623
- summary.textContent =
1624
- orderedIdentifiers.length > 0
1625
- ? `${orderedIdentifiers.length} ${orderedIdentifiers.length === 1 ? "region" : "regions"} ordered.`
1626
- : "No regions ordered";
1627
- list.replaceChildren(...currentChoices.map((choice, index) => {
1628
- const item = document.createElement("li");
1629
- item.className = "qti3-graphic-order-item";
1630
- item.dataset.choiceIdentifier = choice.identifier;
1631
- const choiceLabel = hotspotDisplayLabel(choice, choices);
1632
- const label = document.createElement("button");
1633
- label.type = "button";
1634
- label.className = "qti3-token";
1635
- label.dataset.choiceIdentifier = choice.identifier;
1636
- label.textContent = `${index + 1}. ${choiceLabel}`;
1637
- label.setAttribute("aria-label", `${choiceLabel}, position ${index + 1} of ${currentChoices.length}`);
1638
- label.addEventListener("click", () => focusHotspot(choice.identifier));
1639
- label.addEventListener("keydown", (event) => {
1640
- if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
1641
- event.preventDefault();
1642
- moveHotspot(choice.identifier, -1);
1643
- }
1644
- else if (event.key === "ArrowDown" || event.key === "ArrowRight") {
1645
- event.preventDefault();
1646
- moveHotspot(choice.identifier, 1);
1647
- }
1648
- else if (event.key === "Delete" || event.key === "Backspace") {
1649
- event.preventDefault();
1650
- removeHotspot(choice.identifier);
1651
- }
1652
- });
1653
- const up = movementButton("up", movementLabel(choiceLabel, "up"), () => moveHotspot(choice.identifier, -1));
1654
- up.disabled = index === 0;
1655
- const down = movementButton("down", movementLabel(choiceLabel, "down"), () => moveHotspot(choice.identifier, 1));
1656
- down.disabled = index === currentChoices.length - 1;
1657
- const remove = removeButton(choiceLabel, messages);
1658
- remove.addEventListener("click", () => removeHotspot(choice.identifier));
1659
- item.append(label, up, down, remove);
1660
- return item;
1661
- }));
1662
- };
1663
- for (const [index, choice] of choices.entries()) {
1664
- const button = document.createElement("button");
1665
- button.type = "button";
1666
- button.className = "qti3-hotspot-button qti3-graphic-order-hotspot";
1667
- button.dataset.choiceIdentifier = choice.identifier;
1668
- button.title = hotspotAccessibleLabel(choice, index);
1669
- button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1670
- button.setAttribute("aria-pressed", "false");
1671
- button.style.position = "absolute";
1672
- placeHotspotButton(button, choice, width, height);
1673
- const text = document.createElement("span");
1674
- text.className = "qti3-hotspot-label";
1675
- text.textContent = hotspotDisplayLabel(choice, choices);
1676
- const order = document.createElement("span");
1677
- order.className = "qti3-graphic-order-number";
1678
- order.setAttribute("aria-hidden", "true");
1679
- button.append(text, order);
1680
- button.addEventListener("click", () => chooseHotspot(choice));
1681
- button.addEventListener("keydown", (event) => {
1682
- if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1683
- event.preventDefault();
1684
- focusRelativeHotspot(choice, 1);
1685
- }
1686
- else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
1687
- event.preventDefault();
1688
- focusRelativeHotspot(choice, -1);
1689
- }
1690
- else if (event.key === "Delete" || event.key === "Backspace") {
1691
- event.preventDefault();
1692
- removeHotspot(choice.identifier);
1693
- }
1694
- });
1695
- surface.append(button);
1696
- }
1697
- renderState();
1698
- group.append(surface, summary, list);
1699
- return group;
1700
- }
1701
- function renderGraphicAssociateResponse(interaction, update, currentValue, messages) {
1702
- const group = responseGroup();
1703
- const width = objectWidth(interaction);
1704
- const height = objectHeight(interaction);
1705
- const choices = choicesOrFallback(interaction).filter((choice) => choice.role === "hotspot");
1706
- const selectedPairs = valueToStrings(currentValue);
1707
- const maximumAssociations = interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
1708
- let selectedHotspot;
1709
- let draggedHotspot;
1710
- let dragPointerId;
1711
- let dragStart;
1712
- let dragStarted = false;
1713
- let suppressNextClick = false;
1714
- let previewLine;
1715
- const surface = document.createElement("div");
1716
- surface.className = "qti3-graphic-associate-surface";
1717
- surface.role = "group";
1718
- surface.setAttribute("aria-label", `${readableType(interaction.type)} hotspots`);
1719
- surface.style.position = "relative";
1720
- surface.style.inlineSize = `${width}px`;
1721
- surface.style.aspectRatio = `${width} / ${height}`;
1722
- surface.style.maxInlineSize = "100%";
1723
- surface.style.border = "1px solid CanvasText";
1724
- surface.style.background = "Canvas";
1725
- surface.style.overflow = "hidden";
1726
- const object = interaction.object;
1727
- if (object?.data && objectIsImage(object)) {
1728
- const image = document.createElement("img");
1729
- image.src = object.data;
1730
- image.alt = object.text || `${readableType(interaction.type)} image`;
1731
- image.style.inlineSize = "100%";
1732
- image.style.blockSize = "100%";
1733
- image.style.objectFit = "contain";
1734
- surface.append(image);
1735
- }
1736
- const connections = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1737
- connections.classList.add("qti3-graphic-associate-lines");
1738
- connections.setAttribute("viewBox", `0 0 ${width} ${height}`);
1739
- connections.setAttribute("aria-hidden", "true");
1740
- surface.append(connections);
1741
- const summary = document.createElement("p");
1742
- summary.className = "qti3-selection-summary";
1743
- summary.setAttribute("aria-live", "polite");
1744
- const pairList = document.createElement("ul");
1745
- pairList.className = "qti3-pair-list";
1746
- pairList.setAttribute("aria-label", `${readableType(interaction.type)} selected pairs`);
1747
- const commit = () => {
1748
- if (interaction.responseCardinality === "single")
1749
- update(selectedPairs[0] ?? null);
1750
- else
1751
- update([...selectedPairs]);
1752
- };
1753
- const removePair = (pair) => {
1754
- const index = selectedPairs.indexOf(pair);
1755
- if (index < 0)
1756
- return;
1757
- selectedPairs.splice(index, 1);
1758
- renderState();
1759
- commit();
1760
- };
1761
- const removePairsForHotspot = (identifier) => {
1762
- let removed = false;
1763
- for (let index = selectedPairs.length - 1; index >= 0; index -= 1) {
1764
- const [source, target] = selectedPairs[index]?.split(" ") ?? [];
1765
- if (source === identifier || target === identifier) {
1766
- selectedPairs.splice(index, 1);
1767
- removed = true;
1768
- }
1769
- }
1770
- if (!removed)
1771
- return;
1772
- renderState();
1773
- commit();
1774
- };
1775
- const addPair = (source, target) => {
1776
- if (source.identifier === target.identifier) {
1777
- selectedHotspot = undefined;
1778
- renderState();
1779
- return;
1780
- }
1781
- const pair = `${source.identifier} ${target.identifier}`;
1782
- if (!selectedPairs.includes(pair)) {
1783
- if (interaction.responseCardinality === "single")
1784
- selectedPairs.splice(0);
1785
- if (maximumAssociations !== undefined &&
1786
- selectedPairs.length >= maximumAssociations &&
1787
- interaction.responseCardinality !== "single") {
1788
- selectedHotspot = undefined;
1789
- renderState();
1790
- return;
1791
- }
1792
- if (exceedsHotspotMatchMax(source, selectedPairs) ||
1793
- exceedsHotspotMatchMax(target, selectedPairs)) {
1794
- selectedHotspot = undefined;
1795
- renderState();
1796
- return;
1797
- }
1798
- selectedPairs.push(pair);
1799
- }
1800
- selectedHotspot = undefined;
1801
- renderState();
1802
- commit();
1803
- };
1804
- const authoredPointFromPointer = (event) => {
1805
- const rect = surface.getBoundingClientRect();
1806
- return {
1807
- x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
1808
- y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
1809
- };
1810
- };
1811
- const removePreviewLine = () => {
1812
- previewLine?.remove();
1813
- previewLine = undefined;
1814
- };
1815
- const suppressFollowingClick = () => {
1816
- suppressNextClick = true;
1817
- setTimeout(() => {
1818
- suppressNextClick = false;
1819
- }, 0);
1820
- };
1821
- const updatePreviewLine = (source, event) => {
1822
- const start = hotspotCenter(source, width, height);
1823
- const end = authoredPointFromPointer(event);
1824
- if (!previewLine) {
1825
- previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
1826
- previewLine.dataset.preview = "true";
1827
- connections.append(previewLine);
1828
- }
1829
- previewLine.setAttribute("x1", String(start.x));
1830
- previewLine.setAttribute("y1", String(start.y));
1831
- previewLine.setAttribute("x2", String(end.x));
1832
- previewLine.setAttribute("y2", String(end.y));
1833
- };
1834
- const hotspotFromPointer = (event) => {
1835
- const element = document.elementFromPoint(event.clientX, event.clientY);
1836
- const button = element?.closest(".qti3-graphic-associate-hotspot");
1837
- const identifier = button?.dataset.choiceIdentifier;
1838
- return choices.find((choice) => choice.identifier === identifier);
1839
- };
1840
- const finishDrag = (event, source) => {
1841
- const target = hotspotFromPointer(event);
1842
- removePreviewLine();
1843
- if (target) {
1844
- addPair(source, target);
1845
- return;
1846
- }
1847
- selectedHotspot = undefined;
1848
- renderState();
1849
- };
1850
- const chooseHotspot = (choice) => {
1851
- if (!selectedHotspot) {
1852
- selectedHotspot = choice;
1853
- renderState();
1854
- return;
1855
- }
1856
- addPair(selectedHotspot, choice);
1857
- };
1858
- const focusRelativeHotspot = (choice, delta) => {
1859
- const index = choices.findIndex((entry) => entry.identifier === choice.identifier);
1860
- const next = choices[(index + delta + choices.length) % choices.length];
1861
- if (!next)
1862
- return;
1863
- surface
1864
- .querySelector(`[data-choice-identifier="${next.identifier}"]`)
1865
- ?.focus();
1866
- };
1867
- const renderState = () => {
1868
- connections.replaceChildren(...selectedPairs.flatMap((pair) => {
1869
- const [sourceIdentifier, targetIdentifier] = pair.split(" ");
1870
- const source = choices.find((choice) => choice.identifier === sourceIdentifier);
1871
- const target = choices.find((choice) => choice.identifier === targetIdentifier);
1872
- if (!source || !target)
1873
- return [];
1874
- const start = hotspotCenter(source, width, height);
1875
- const end = hotspotCenter(target, width, height);
1876
- const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
1877
- line.setAttribute("x1", String(start.x));
1878
- line.setAttribute("y1", String(start.y));
1879
- line.setAttribute("x2", String(end.x));
1880
- line.setAttribute("y2", String(end.y));
1881
- return [line];
1882
- }));
1883
- for (const button of surface.querySelectorAll(".qti3-hotspot-button")) {
1884
- const identifier = button.dataset.choiceIdentifier ?? "";
1885
- const isActive = identifier === selectedHotspot?.identifier;
1886
- const isPaired = selectedPairs.some((pair) => pair.split(" ").includes(identifier));
1887
- button.setAttribute("aria-pressed", isActive ? "true" : "false");
1888
- button.dataset.selected = isActive || isPaired ? "true" : "false";
1889
- }
1890
- summary.textContent = selectedHotspot
1891
- ? `${hotspotDisplayLabel(selectedHotspot, choices)} selected. Choose another hotspot.`
1892
- : selectedPairs.length > 0
1893
- ? `${selectedPairs.length} ${selectedPairs.length === 1 ? "association" : "associations"} made.`
1894
- : "No associations made";
1895
- pairList.replaceChildren(...selectedPairs.map((pair) => {
1896
- const [source, target] = pair.split(" ");
1897
- const sourceChoice = choices.find((choice) => choice.identifier === source);
1898
- const targetChoice = choices.find((choice) => choice.identifier === target);
1899
- const pairLabel = `${sourceChoice ? hotspotDisplayLabel(sourceChoice, choices) : source} to ${targetChoice ? hotspotDisplayLabel(targetChoice, choices) : target}`;
1900
- const item = document.createElement("li");
1901
- item.className = "qti3-pair-chip";
1902
- const text = document.createElement("span");
1903
- text.textContent = pairLabel;
1904
- const remove = removeButton(pairLabel, messages);
1905
- remove.addEventListener("click", () => removePair(pair));
1906
- item.append(text, remove);
1907
- return item;
1908
- }));
1909
- };
1910
- for (const [index, choice] of choices.entries()) {
1911
- const button = document.createElement("button");
1912
- button.type = "button";
1913
- button.className = "qti3-hotspot-button qti3-graphic-associate-hotspot";
1914
- button.dataset.choiceIdentifier = choice.identifier;
1915
- button.textContent = hotspotDisplayLabel(choice, choices);
1916
- button.title = hotspotAccessibleLabel(choice, index);
1917
- button.setAttribute("aria-pressed", "false");
1918
- button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
1919
- button.style.position = "absolute";
1920
- button.style.touchAction = "none";
1921
- placeHotspotButton(button, choice, width, height);
1922
- button.addEventListener("click", (event) => {
1923
- if (suppressNextClick) {
1924
- suppressNextClick = false;
1925
- event.preventDefault();
1926
- return;
1927
- }
1928
- chooseHotspot(choice);
1929
- });
1930
- button.addEventListener("pointerdown", (event) => {
1931
- if (event.button !== 0)
1932
- return;
1933
- draggedHotspot = choice;
1934
- dragPointerId = event.pointerId;
1935
- dragStart = { x: event.clientX, y: event.clientY };
1936
- dragStarted = false;
1937
- button.setPointerCapture(event.pointerId);
1938
- });
1939
- button.addEventListener("pointermove", (event) => {
1940
- if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart)
1941
- return;
1942
- const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
1943
- if (!dragStarted && moved < 4)
1944
- return;
1945
- if (!dragStarted) {
1946
- dragStarted = true;
1947
- suppressFollowingClick();
1948
- selectedHotspot = draggedHotspot;
1949
- renderState();
1950
- }
1951
- updatePreviewLine(draggedHotspot, event);
1952
- event.preventDefault();
1953
- });
1954
- button.addEventListener("pointerup", (event) => {
1955
- if (dragPointerId !== event.pointerId || !draggedHotspot)
1956
- return;
1957
- const source = draggedHotspot;
1958
- draggedHotspot = undefined;
1959
- dragPointerId = undefined;
1960
- dragStart = undefined;
1961
- button.releasePointerCapture(event.pointerId);
1962
- if (!dragStarted)
1963
- return;
1964
- dragStarted = false;
1965
- suppressFollowingClick();
1966
- finishDrag(event, source);
1967
- event.preventDefault();
1968
- });
1969
- button.addEventListener("pointercancel", (event) => {
1970
- if (dragPointerId !== event.pointerId)
1971
- return;
1972
- draggedHotspot = undefined;
1973
- dragPointerId = undefined;
1974
- dragStart = undefined;
1975
- dragStarted = false;
1976
- removePreviewLine();
1977
- selectedHotspot = undefined;
1978
- renderState();
1979
- });
1980
- button.addEventListener("keydown", (event) => {
1981
- if (event.key === "ArrowRight" || event.key === "ArrowDown") {
1982
- event.preventDefault();
1983
- focusRelativeHotspot(choice, 1);
1984
- }
1985
- else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
1986
- event.preventDefault();
1987
- focusRelativeHotspot(choice, -1);
1988
- }
1989
- else if (event.key === "Delete" || event.key === "Backspace") {
1990
- event.preventDefault();
1991
- removePairsForHotspot(choice.identifier);
1992
- }
1993
- });
1994
- surface.append(button);
1995
- }
1996
- renderState();
1997
- group.append(surface, summary, pairList);
1998
- return group;
1999
- }
2000
- function renderGapMatchResponse(interaction, update, currentValue) {
2001
- if (interaction.type === "graphicGapMatch" &&
2002
- interaction.object &&
2003
- interaction.choices.some((choice) => choice.role === "hotspot")) {
2004
- return renderGraphicGapMatchResponse(interaction, update, currentValue);
2005
- }
2006
- const group = responseGroup();
2007
- appendGraphicContext(group, interaction);
2008
- const sources = sourceChoices(interaction);
2009
- const gaps = targetChoices(interaction);
2010
- const assignments = new Map();
2011
- let selectedSource;
2012
- let draggedSource;
2013
- const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
2014
- const gapRegion = document.createElement("div");
2015
- gapRegion.className = "qti3-gap-region qti3-gap-passage";
2016
- gapRegion.role = "group";
2017
- gapRegion.setAttribute("aria-label", `${readableType(interaction.type)} targets`);
2018
- for (const pair of valueToStrings(currentValue)) {
2019
- const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
2020
- const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2021
- if (source && gapIdentifier)
2022
- assignments.set(gapIdentifier, source);
2023
- }
2024
- const commit = () => {
2025
- update([...assignments.entries()].map(([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`));
2026
- };
2027
- const syncSources = () => {
2028
- for (const button of sourceRegion.querySelectorAll("button")) {
2029
- button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false");
2030
- }
2031
- };
2032
- const assign = (gap, sourceIdentifier) => {
2033
- const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2034
- if (!source)
2035
- return;
2036
- assignments.set(gap.identifier, source);
2037
- selectedSource = undefined;
2038
- syncSources();
2039
- renderGaps();
2040
- commit();
2041
- };
2042
- const gapControl = (gap, index) => {
2043
- const assigned = assignments.get(gap.identifier);
2044
- const gapLabel = `Gap ${index + 1}`;
2045
- const target = document.createElement("span");
2046
- target.className = "qti3-gap-target";
2047
- target.dataset.gapIdentifier = gap.identifier;
2048
- target.addEventListener("dragover", (event) => {
2049
- event.preventDefault();
2050
- target.classList.add("qti3-drop-target");
2051
- });
2052
- target.addEventListener("dragleave", () => target.classList.remove("qti3-drop-target"));
2053
- target.addEventListener("drop", (event) => {
2054
- event.preventDefault();
2055
- target.classList.remove("qti3-drop-target");
2056
- assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
2057
- });
2058
- const button = document.createElement("button");
2059
- button.type = "button";
2060
- button.className = "qti3-gap-button";
2061
- button.textContent = assigned ? assigned.text : "";
2062
- button.setAttribute("aria-label", assigned ? `${gapLabel}, assigned ${assigned.text}` : `${gapLabel}, empty`);
2063
- button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
2064
- button.addEventListener("keydown", (event) => {
2065
- if (event.key !== "Delete" && event.key !== "Backspace")
2066
- return;
2067
- if (!assignments.has(gap.identifier))
2068
- return;
2069
- event.preventDefault();
2070
- assignments.delete(gap.identifier);
2071
- renderGaps();
2072
- commit();
2073
- });
2074
- target.append(button);
2075
- return target;
2076
- };
2077
- const renderGaps = () => {
2078
- const segments = interaction.gapMatchSegments ?? [];
2079
- const hasInlineGaps = segments.some((segment) => segment.kind === "gap");
2080
- if (!hasInlineGaps) {
2081
- gapRegion.replaceChildren(...gaps.map((gap, index) => gapControl(gap, index)));
2082
- return;
2083
- }
2084
- const content = [];
2085
- for (const [segmentIndex, segment] of segments.entries()) {
2086
- if (segment.kind === "text") {
2087
- content.push(document.createTextNode(normalizeInlineSegmentText(segment.text)));
2088
- continue;
2089
- }
2090
- const gapIndex = gaps.findIndex((gap) => gap.identifier === segment.identifier);
2091
- const gap = gaps[gapIndex];
2092
- if (gap) {
2093
- appendInlineControl(content, gapControl(gap, gapIndex), segments[segmentIndex + 1]);
2094
- }
2095
- }
2096
- gapRegion.replaceChildren(...content);
2097
- };
2098
- for (const source of sources) {
2099
- const button = tokenButton(source);
2100
- button.draggable = true;
2101
- button.addEventListener("dragstart", (event) => {
2102
- draggedSource = source.identifier;
2103
- event.dataTransfer?.setData("text/plain", source.identifier);
2104
- });
2105
- button.addEventListener("click", () => {
2106
- selectedSource = source;
2107
- syncSources();
2108
- });
2109
- sourceRegion.append(button);
2110
- }
2111
- renderGaps();
2112
- group.append(sourceRegion, gapRegion);
2113
- return group;
2114
- }
2115
- function renderGraphicGapMatchResponse(interaction, update, currentValue) {
2116
- const group = responseGroup();
2117
- const width = objectWidth(interaction);
2118
- const height = objectHeight(interaction);
2119
- const sources = sourceChoices(interaction);
2120
- const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
2121
- const assignments = new Map();
2122
- let selectedSource;
2123
- let draggedSource;
2124
- for (const pair of valueToStrings(currentValue)) {
2125
- const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
2126
- const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2127
- if (source && gapIdentifier)
2128
- assignments.set(gapIdentifier, source);
2129
- }
2130
- const surface = document.createElement("div");
2131
- surface.className = "qti3-graphic-context qti3-graphic-gap-match-surface";
2132
- surface.role = "group";
2133
- surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
2134
- surface.style.position = "relative";
2135
- surface.style.inlineSize = `${width}px`;
2136
- surface.style.aspectRatio = `${width} / ${height}`;
2137
- surface.style.maxInlineSize = "100%";
2138
- surface.style.border = "1px solid CanvasText";
2139
- surface.style.background = "Canvas";
2140
- surface.style.overflow = "visible";
2141
- surface.style.setProperty("--qti3-graphic-gap-label-block-size", `${graphicGapLabelBlockSize(sources)}rem`);
2142
- if (interaction.object?.data && objectIsImage(interaction.object)) {
2143
- const image = document.createElement("img");
2144
- image.src = interaction.object.data;
2145
- image.alt = interaction.object.text || `${readableType(interaction.type)} image`;
2146
- image.style.position = "absolute";
2147
- image.style.inset = "0";
2148
- image.style.inlineSize = "100%";
2149
- image.style.blockSize = "100%";
2150
- image.style.objectFit = "contain";
2151
- image.style.pointerEvents = "none";
2152
- surface.append(image);
2153
- }
2154
- const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
2155
- sourceRegion.classList.add("qti3-graphic-gap-source-region");
2156
- const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
2157
- if (choicesWidth !== undefined)
2158
- sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
2159
- const summary = document.createElement("p");
2160
- summary.className = "qti3-selection-summary";
2161
- summary.setAttribute("aria-live", "polite");
2162
- const commit = () => {
2163
- update([...assignments.entries()].map(([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`));
2164
- };
2165
- const syncSources = () => {
2166
- for (const button of sourceRegion.querySelectorAll("button")) {
2167
- button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false");
2168
- }
2169
- };
2170
- const clearSourceIfSingleUse = (source, keepGapIdentifier) => {
2171
- if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1)
2172
- return;
2173
- for (const [gapIdentifier, assigned] of assignments.entries()) {
2174
- if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
2175
- assignments.delete(gapIdentifier);
2176
- }
2177
- }
2178
- };
2179
- const assign = (gap, sourceIdentifier) => {
2180
- const source = sources.find((choice) => choice.identifier === sourceIdentifier);
2181
- if (!source)
2182
- return;
2183
- clearSourceIfSingleUse(source, gap.identifier);
2184
- assignments.set(gap.identifier, source);
2185
- selectedSource = undefined;
2186
- syncSources();
2187
- renderTargets();
2188
- commit();
2189
- };
2190
- const targetLabel = (gap, index) => gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
2191
- const renderTargetButton = (gap, index) => {
2192
- const assigned = assignments.get(gap.identifier);
2193
- const label = targetLabel(gap, index);
2194
- const button = document.createElement("button");
2195
- button.type = "button";
2196
- button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
2197
- button.dataset.gapIdentifier = gap.identifier;
2198
- button.dataset.selected = assigned ? "true" : "false";
2199
- button.setAttribute("aria-label", assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`);
2200
- button.addEventListener("dragover", (event) => {
2201
- event.preventDefault();
2202
- button.classList.add("qti3-drop-target");
2203
- });
2204
- button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
2205
- button.addEventListener("drop", (event) => {
2206
- event.preventDefault();
2207
- button.classList.remove("qti3-drop-target");
2208
- assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
2209
- });
2210
- button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
2211
- button.addEventListener("keydown", (event) => {
2212
- if (event.key !== "Delete" && event.key !== "Backspace")
2213
- return;
2214
- if (!assignments.has(gap.identifier))
2215
- return;
2216
- event.preventDefault();
2217
- assignments.delete(gap.identifier);
2218
- renderTargets();
2219
- commit();
2220
- });
2221
- button.style.position = "absolute";
2222
- placeHotspotButton(button, gap, width, height);
2223
- if (assigned) {
2224
- const assignedLabel = document.createElement("span");
2225
- assignedLabel.className = "qti3-graphic-gap-label";
2226
- assignedLabel.textContent = assigned.text;
2227
- button.append(assignedLabel);
2228
- }
2229
- return button;
2230
- };
2231
- const renderTargets = () => {
2232
- surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
2233
- for (const [index, gap] of gaps.entries()) {
2234
- surface.append(renderTargetButton(gap, index));
2235
- }
2236
- summary.textContent =
2237
- assignments.size > 0
2238
- ? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
2239
- : "No labels placed.";
2240
- };
2241
- for (const source of sources) {
2242
- const button = tokenButton(source);
2243
- button.draggable = true;
2244
- button.addEventListener("dragstart", (event) => {
2245
- draggedSource = source.identifier;
2246
- event.dataTransfer?.setData("text/plain", source.identifier);
2247
- event.dataTransfer?.setDragImage(button, 8, 8);
2248
- });
2249
- button.addEventListener("dragend", () => {
2250
- draggedSource = undefined;
2251
- syncSources();
2252
- });
2253
- button.addEventListener("click", () => {
2254
- selectedSource = source;
2255
- syncSources();
2256
- });
2257
- sourceRegion.append(button);
2258
- }
2259
- renderTargets();
2260
- group.append(surface, sourceRegion, summary);
2261
- return group;
2262
- }
2263
- function renderSelect(interaction, update, currentValue) {
2264
- const select = document.createElement("select");
2265
- select.className = "qti3-inline-select";
2266
- select.setAttribute("aria-label", interactionLabel(interaction));
2267
- appendOptions(select, choicesOrFallback(interaction));
2268
- const [selected] = valueToStrings(currentValue);
2269
- if (selected)
2270
- select.value = selected;
2271
- select.addEventListener("change", () => update(select.value === "" ? null : select.value));
2272
- return select;
2273
- }
2274
- function appendInlineControl(content, control, nextSegment) {
2275
- const previous = content.at(-1);
2276
- if (previous instanceof Text && !/\s$/.test(previous.data)) {
2277
- content.push(document.createTextNode(" "));
2278
- }
2279
- content.push(control);
2280
- const nextText = nextSegment?.kind === "text" ? normalizeInlineSegmentText(nextSegment.text) : undefined;
2281
- if (nextText && !/^\s|^[,.;:!?]/.test(nextText)) {
2282
- content.push(document.createTextNode(" "));
2283
- }
2284
- }
2285
- function normalizeInlineSegmentText(value) {
2286
- return (value ?? "").replace(/\s+([,.;:!?])/g, "$1");
2287
- }
2288
- function interactionLabel(interaction) {
2289
- return interaction.prompt ?? interaction.contextText ?? readableType(interaction.type);
2290
- }
2291
- function qtiSharedClassNames(value) {
2292
- return (value ?? "").split(/\s+/).filter((className) => className.startsWith("qti-"));
2293
- }
2294
- function renderTextResponse(interaction, update, mode, currentValue) {
2295
- const group = document.createElement("div");
2296
- group.className = "qti3-text-response";
2297
- const expectedLength = Number(interaction.attributes["expected-length"] ?? 0);
2298
- const expectedLines = Number(interaction.attributes["expected-lines"] ?? 0);
2299
- const control = mode === "extended" ? document.createElement("textarea") : document.createElement("input");
2300
- control.className = mode === "extended" ? "qti3-textarea" : "qti3-text-input";
2301
- control.value = scalarString(currentValue);
2302
- control.setAttribute("aria-label", interaction.prompt ?? (mode === "extended" ? "Extended text response" : "Text response"));
2303
- if (mode === "extended" && expectedLines > 0) {
2304
- control.rows = expectedLines;
2305
- }
2306
- if (mode === "entry") {
2307
- applyExpectedTextEntryWidth(control, expectedLength);
2308
- }
2309
- const counter = mode === "extended" ? document.createElement("p") : undefined;
2310
- if (counter) {
2311
- counter.className = "qti3-counter";
2312
- counter.setAttribute("aria-live", "polite");
2313
- }
2314
- const sync = (emitResponse = true) => {
2315
- const value = control.value;
2316
- if (counter) {
2317
- const words = value.trim().length > 0 ? value.trim().split(/\s+/).length : 0;
2318
- counter.textContent = `${value.length} characters, ${words} words`;
2319
- }
2320
- if (emitResponse)
2321
- update(value);
2322
- };
2323
- control.addEventListener("input", () => sync());
2324
- control.addEventListener("change", () => sync());
2325
- sync(false);
2326
- group.append(control);
2327
- if (counter)
2328
- group.append(counter);
2329
- return group;
2330
- }
2331
- function applyExpectedTextEntryWidth(control, expectedLength) {
2332
- if (!(control instanceof HTMLInputElement) || expectedLength <= 0)
2333
- return;
2334
- const width = Math.max(8, Math.min(expectedLength + 2, 72));
2335
- control.style.inlineSize = `${width}ch`;
2336
- }
2337
- function renderInlineTextEntry(interaction, update, currentValue) {
2338
- const group = document.createElement("span");
2339
- group.className = "qti3-inline-text-response";
2340
- const input = document.createElement("input");
2341
- input.className = "qti3-text-input qti3-inline-text-input";
2342
- input.value = scalarString(currentValue);
2343
- input.setAttribute("aria-label", interaction.prompt ?? interaction.contextText ?? "Text response");
2344
- const expectedLength = Number(interaction.attributes["expected-length"] ?? 0);
2345
- applyExpectedTextEntryWidth(input, expectedLength);
2346
- const sync = (emitResponse = true) => {
2347
- if (emitResponse)
2348
- update(input.value);
2349
- };
2350
- input.addEventListener("input", () => sync());
2351
- input.addEventListener("change", () => sync());
2352
- sync(false);
2353
- group.append(input);
2354
- return group;
2355
- }
2356
- function renderSliderResponse(interaction, update, currentValue) {
2357
- const group = document.createElement("div");
2358
- group.className = "qti3-slider-response";
2359
- const input = document.createElement("input");
2360
- input.type = "range";
2361
- input.min = interaction.attributes["lower-bound"] ?? "0";
2362
- input.max = interaction.attributes["upper-bound"] ?? "100";
2363
- input.step = interaction.attributes.step ?? "1";
2364
- input.value = scalarString(currentValue) || interaction.attributes["lower-bound"] || "0";
2365
- input.setAttribute("aria-label", interaction.prompt ?? "Slider response");
2366
- const output = document.createElement("output");
2367
- output.className = "qti3-slider-output";
2368
- output.value = input.value;
2369
- output.textContent = input.value;
2370
- const sync = () => {
2371
- output.value = input.value;
2372
- output.textContent = input.value;
2373
- update(coerceResponseInputValue(input.value, interaction.responseBaseType));
2374
- };
2375
- input.addEventListener("input", sync);
2376
- group.append(input, output);
2377
- return group;
2378
- }
2379
- function appendGraphicContext(group, interaction) {
2380
- if (!interaction.type.startsWith("graphic") || !interaction.object)
2381
- return;
2382
- const context = document.createElement("div");
2383
- context.className = "qti3-graphic-context";
2384
- context.append(renderObjectAsset(interaction));
2385
- group.append(context);
2386
- }
2387
- function renderSelectPointResponse(interaction, update, currentValue) {
2388
- const group = document.createElement("div");
2389
- group.role = "group";
2390
- group.setAttribute("aria-label", `${readableType(interaction.type)} coordinate response`);
2391
- const isMultiple = interaction.responseCardinality === "multiple";
2392
- const maxPoints = isMultiple ? maximumAllowedResponses(interaction) : 1;
2393
- const surface = document.createElement("button");
2394
- surface.type = "button";
2395
- surface.className = "qti3-point-surface";
2396
- surface.setAttribute("aria-label", `${readableType(interaction.type)} coordinate area`);
2397
- surface.style.display = "block";
2398
- surface.style.position = "relative";
2399
- surface.style.inlineSize = `min(100%, ${objectWidth(interaction)}px)`;
2400
- surface.style.aspectRatio = `${objectWidth(interaction)} / ${objectHeight(interaction)}`;
2401
- surface.style.boxSizing = "border-box";
2402
- surface.style.border = "1px solid CanvasText";
2403
- surface.style.background = "Canvas";
2404
- surface.style.color = "CanvasText";
2405
- surface.style.cursor = "crosshair";
2406
- surface.style.overflow = "hidden";
2407
- const object = interaction.object;
2408
- if (object?.data && object.type?.startsWith("image/")) {
2409
- const image = document.createElement("img");
2410
- image.src = object.data;
2411
- image.alt = "";
2412
- image.style.position = "absolute";
2413
- image.style.inset = "0";
2414
- image.style.inlineSize = "100%";
2415
- image.style.blockSize = "100%";
2416
- image.style.objectFit = "contain";
2417
- image.style.pointerEvents = "none";
2418
- surface.append(image);
2419
- }
2420
- const width = objectWidth(interaction);
2421
- const height = objectHeight(interaction);
2422
- let points = parsePointValues(currentValue);
2423
- let activeIndex = points.length > 0 ? points.length - 1 : -1;
2424
- const coordinate = document.createElement("output");
2425
- coordinate.className = "qti3-coordinate-output";
2426
- const initialPoint = () => ({
2427
- x: Math.round(width / 2),
2428
- y: Math.round(height / 2),
2429
- });
2430
- const emitValue = () => {
2431
- const values = points.map(pointToString);
2432
- if (isMultiple)
2433
- return values;
2434
- return values[0] ?? "";
2435
- };
2436
- const commit = () => {
2437
- update(emitValue());
2438
- };
2439
- const syncMarker = () => {
2440
- surface.querySelectorAll(".qti3-point-marker").forEach((marker) => marker.remove());
2441
- if (points.length === 0) {
2442
- coordinate.value = "";
2443
- coordinate.textContent = "No point selected";
2444
- surface.setAttribute("aria-label", `${readableType(interaction.type)} coordinate area`);
2445
- return;
2446
- }
2447
- points.forEach((point, index) => {
2448
- const marker = document.createElement("span");
2449
- marker.className = "qti3-point-marker";
2450
- marker.setAttribute("aria-hidden", "true");
2451
- marker.style.position = "absolute";
2452
- marker.style.inlineSize = "8px";
2453
- marker.style.blockSize = "8px";
2454
- marker.style.border = "2px solid CanvasText";
2455
- marker.style.borderRadius = "50%";
2456
- marker.style.transform = "translate(-50%, -50%)";
2457
- marker.style.pointerEvents = "none";
2458
- marker.style.insetInlineStart = `${(point.x / width) * 100}%`;
2459
- marker.style.insetBlockStart = `${(point.y / height) * 100}%`;
2460
- if (index === activeIndex)
2461
- marker.dataset.active = "true";
2462
- surface.append(marker);
2463
- });
2464
- const text = points.map(pointToString).join("; ");
2465
- coordinate.value = isMultiple
2466
- ? points.map(pointToString).join(" | ")
2467
- : pointToString(points[0]);
2468
- coordinate.textContent = isMultiple
2469
- ? `${points.length} selected point${points.length === 1 ? "" : "s"}: ${text}`
2470
- : `Selected point ${pointToString(points[0])}`;
2471
- surface.setAttribute("aria-label", `${readableType(interaction.type)} coordinate area, selected ${text}`);
2472
- };
2473
- const clampPoint = (point) => {
2474
- point.x = Math.max(0, Math.min(width, point.x));
2475
- point.y = Math.max(0, Math.min(height, point.y));
2476
- };
2477
- const setActivePoint = (point) => {
2478
- clampPoint(point);
2479
- if (!isMultiple) {
2480
- points = [point];
2481
- activeIndex = 0;
2482
- return;
2483
- }
2484
- if (maxPoints !== undefined && points.length >= maxPoints) {
2485
- points[points.length - 1] = point;
2486
- activeIndex = points.length - 1;
2487
- return;
2488
- }
2489
- points.push(point);
2490
- activeIndex = points.length - 1;
2491
- };
2492
- const mutableActivePoint = () => {
2493
- if (points.length === 0)
2494
- setActivePoint(initialPoint());
2495
- if (activeIndex < 0 || activeIndex >= points.length)
2496
- activeIndex = points.length - 1;
2497
- const point = points[activeIndex];
2498
- if (point)
2499
- return point;
2500
- const fallback = initialPoint();
2501
- points = [fallback];
2502
- activeIndex = 0;
2503
- return fallback;
2504
- };
2505
- surface.addEventListener("click", (event) => {
2506
- if (event.detail === 0)
2507
- return;
2508
- const rect = surface.getBoundingClientRect();
2509
- setActivePoint({
2510
- x: Math.round(((event.clientX - rect.left) / rect.width) * width),
2511
- y: Math.round(((event.clientY - rect.top) / rect.height) * height),
2512
- });
2513
- syncMarker();
2514
- commit();
2515
- });
2516
- surface.addEventListener("keydown", (event) => {
2517
- const point = mutableActivePoint();
2518
- const step = event.shiftKey ? 10 : 1;
2519
- if (event.key === "ArrowLeft")
2520
- point.x -= step;
2521
- else if (event.key === "ArrowRight")
2522
- point.x += step;
2523
- else if (event.key === "ArrowUp")
2524
- point.y -= step;
2525
- else if (event.key === "ArrowDown")
2526
- point.y += step;
2527
- else if (event.key === "Enter" || event.key === " ") {
2528
- event.preventDefault();
2529
- commit();
2530
- return;
2531
- }
2532
- else
2533
- return;
2534
- event.preventDefault();
2535
- clampPoint(point);
2536
- syncMarker();
2537
- });
2538
- syncMarker();
2539
- const controls = document.createElement("div");
2540
- controls.className = "qti3-point-controls";
2541
- for (const [direction, dx, dy] of [
2542
- ["up", 0, -1],
2543
- ["left", -1, 0],
2544
- ["right", 1, 0],
2545
- ["down", 0, 1],
2546
- ]) {
2547
- controls.append(movementButton(direction, movementLabel("point", direction), () => {
2548
- const point = mutableActivePoint();
2549
- point.x += dx;
2550
- point.y += dy;
2551
- clampPoint(point);
2552
- syncMarker();
2553
- commit();
2554
- }));
2555
- }
2556
- if (isMultiple) {
2557
- const clear = document.createElement("button");
2558
- clear.type = "button";
2559
- clear.textContent = "Clear points";
2560
- clear.addEventListener("click", () => {
2561
- points = [];
2562
- activeIndex = -1;
2563
- syncMarker();
2564
- commit();
2565
- });
2566
- controls.append(clear);
2567
- }
2568
- group.append(surface, coordinate, controls);
2569
- return group;
2570
- }
2571
- function renderPositionObjectResponse(interaction, update, currentValue) {
2572
- const group = document.createElement("div");
2573
- group.role = "group";
2574
- group.setAttribute("aria-label", `${readableType(interaction.type)} object placement response`);
2575
- const stageObject = interaction.positionObjectStage ?? interaction.object;
2576
- const movableObject = interaction.positionObjectStage ? interaction.object : undefined;
2577
- const width = objectAssetWidth(stageObject, 480);
2578
- const height = objectAssetHeight(stageObject, 300);
2579
- const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
2580
- const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
2581
- const parsedPoint = parsePointValue(currentValue);
2582
- let point = parsedPoint ?? { x: 0, y: 0 };
2583
- let isPlaced = Boolean(parsedPoint);
2584
- const stage = document.createElement("div");
2585
- stage.className = "qti3-position-object-stage";
2586
- stage.tabIndex = 0;
2587
- stage.role = "group";
2588
- stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage`);
2589
- stage.style.position = "relative";
2590
- stage.style.inlineSize = `min(100%, ${width}px)`;
2591
- stage.style.aspectRatio = `${width} / ${height}`;
2592
- stage.style.boxSizing = "border-box";
2593
- stage.style.border = "1px solid CanvasText";
2594
- stage.style.background = "Canvas";
2595
- stage.style.color = "CanvasText";
2596
- stage.style.overflow = "visible";
2597
- stage.style.touchAction = "none";
2598
- stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
2599
- if (stageObject?.data && objectIsImage(stageObject)) {
2600
- const image = document.createElement("img");
2601
- image.src = stageObject.data;
2602
- image.alt = stageObject.text || "";
2603
- image.style.position = "absolute";
2604
- image.style.inset = "0";
2605
- image.style.inlineSize = "100%";
2606
- image.style.blockSize = "100%";
2607
- image.style.objectFit = "contain";
2608
- image.style.pointerEvents = "none";
2609
- stage.append(image);
2610
- }
2611
- const marker = document.createElement("button");
2612
- marker.type = "button";
2613
- marker.className = "qti3-position-object-marker";
2614
- marker.setAttribute("aria-label", "Movable object");
2615
- marker.style.position = "absolute";
2616
- marker.style.inlineSize = `${movableWidth}px`;
2617
- marker.style.blockSize = `${movableHeight}px`;
2618
- marker.style.transform = "translate(-50%, -50%)";
2619
- marker.style.border = "2px solid CanvasText";
2620
- marker.style.background = "Canvas";
2621
- marker.style.color = "CanvasText";
2622
- marker.style.padding = "0";
2623
- marker.style.cursor = "grab";
2624
- marker.style.touchAction = "none";
2625
- marker.draggable = false;
2626
- if (movableObject?.data && objectIsImage(movableObject)) {
2627
- const image = document.createElement("img");
2628
- image.src = movableObject.data;
2629
- image.alt = "";
2630
- image.style.inlineSize = "100%";
2631
- image.style.blockSize = "100%";
2632
- image.style.objectFit = "contain";
2633
- image.style.pointerEvents = "none";
2634
- marker.append(image);
2635
- }
2636
- else {
2637
- marker.textContent = "Place";
2638
- }
2639
- stage.append(marker);
2640
- const coordinate = document.createElement("output");
2641
- coordinate.className = "qti3-coordinate-output";
2642
- const clamp = () => {
2643
- point.x = Math.max(0, Math.min(width, point.x));
2644
- point.y = Math.max(0, Math.min(height, point.y));
2645
- };
2646
- const commit = () => {
2647
- if (!isPlaced)
2648
- return;
2649
- update(pointToString(point));
2650
- };
2651
- const syncMarker = () => {
2652
- if (!isPlaced) {
2653
- marker.dataset.placed = "false";
2654
- marker.style.insetInlineStart = `${Math.round(movableWidth / 2)}px`;
2655
- marker.style.insetBlockStart = `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`;
2656
- coordinate.value = "";
2657
- coordinate.textContent = "Object not placed";
2658
- stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage, object not placed`);
2659
- return;
2660
- }
2661
- clamp();
2662
- marker.dataset.placed = "true";
2663
- marker.style.insetInlineStart = `${percent(point.x, width)}%`;
2664
- marker.style.insetBlockStart = `${percent(point.y, height)}%`;
2665
- coordinate.value = pointToString(point);
2666
- coordinate.textContent = `Object positioned at ${pointToString(point)}`;
2667
- stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage, object at ${pointToString(point)}`);
2668
- };
2669
- const pointFromPointer = (event) => {
2670
- const rect = stage.getBoundingClientRect();
2671
- point = {
2672
- x: Math.round(((event.clientX - rect.left) / rect.width) * width),
2673
- y: Math.round(((event.clientY - rect.top) / rect.height) * height),
2674
- };
2675
- isPlaced = true;
2676
- clamp();
2677
- };
2678
- const ensureKeyboardPoint = () => {
2679
- if (isPlaced)
2680
- return;
2681
- point = { x: 0, y: 0 };
2682
- isPlaced = true;
2683
- };
2684
- const moveBy = (dx, dy, emit = true) => {
2685
- ensureKeyboardPoint();
2686
- point.x += dx;
2687
- point.y += dy;
2688
- syncMarker();
2689
- if (emit)
2690
- commit();
2691
- };
2692
- const handleKey = (event) => {
2693
- const step = event.shiftKey ? 10 : 1;
2694
- if (event.key === "ArrowLeft")
2695
- moveBy(-step, 0, false);
2696
- else if (event.key === "ArrowRight")
2697
- moveBy(step, 0, false);
2698
- else if (event.key === "ArrowUp")
2699
- moveBy(0, -step, false);
2700
- else if (event.key === "ArrowDown")
2701
- moveBy(0, step, false);
2702
- else if (event.key === "Enter" || event.key === " ") {
2703
- ensureKeyboardPoint();
2704
- syncMarker();
2705
- commit();
2706
- }
2707
- else
2708
- return;
2709
- event.preventDefault();
2710
- };
2711
- let dragging = false;
2712
- let dragMoved = false;
2713
- marker.addEventListener("pointerdown", (event) => {
2714
- dragging = true;
2715
- dragMoved = false;
2716
- marker.setPointerCapture(event.pointerId);
2717
- marker.style.cursor = "grabbing";
2718
- if (isPlaced) {
2719
- pointFromPointer(event);
2720
- syncMarker();
2721
- }
2722
- event.preventDefault();
2723
- });
2724
- marker.addEventListener("pointermove", (event) => {
2725
- if (!dragging)
2726
- return;
2727
- dragMoved = true;
2728
- pointFromPointer(event);
2729
- syncMarker();
2730
- });
2731
- marker.addEventListener("pointerup", (event) => {
2732
- if (!dragging)
2733
- return;
2734
- dragging = false;
2735
- marker.releasePointerCapture(event.pointerId);
2736
- marker.style.cursor = "grab";
2737
- if (dragMoved || isPlaced) {
2738
- pointFromPointer(event);
2739
- syncMarker();
2740
- commit();
2741
- }
2742
- });
2743
- marker.addEventListener("pointercancel", () => {
2744
- dragging = false;
2745
- marker.style.cursor = "grab";
2746
- });
2747
- stage.addEventListener("click", (event) => {
2748
- if (event.target === marker)
2749
- return;
2750
- pointFromPointer(event);
2751
- syncMarker();
2752
- commit();
2753
- });
2754
- stage.addEventListener("keydown", handleKey);
2755
- marker.addEventListener("keydown", handleKey);
2756
- const controls = document.createElement("div");
2757
- controls.className = "qti3-point-controls";
2758
- for (const [direction, dx, dy] of [
2759
- ["up", 0, -1],
2760
- ["left", -1, 0],
2761
- ["right", 1, 0],
2762
- ["down", 0, 1],
2763
- ]) {
2764
- controls.append(movementButton(direction, movementLabel("object", direction), () => moveBy(dx, dy)));
2765
- }
2766
- syncMarker();
2767
- group.append(stage, coordinate, controls);
2768
- return group;
2769
- }
2770
- function renderDrawingResponse(interaction, update, currentValue) {
2771
- const group = document.createElement("div");
2772
- group.role = "group";
2773
- group.setAttribute("aria-label", `${readableType(interaction.type)} response`);
2774
- const surface = document.createElementNS("http://www.w3.org/2000/svg", "svg");
2775
- surface.classList.add("qti3-drawing-surface");
2776
- surface.setAttribute("role", "img");
2777
- surface.setAttribute("aria-label", "Drawing response surface");
2778
- surface.setAttribute("tabindex", "0");
2779
- const width = drawingWidth(interaction);
2780
- const height = drawingHeight(interaction);
2781
- surface.setAttribute("viewBox", `0 0 ${width} ${height}`);
2782
- surface.style.display = "block";
2783
- surface.style.inlineSize = `${width}px`;
2784
- surface.style.aspectRatio = `${width} / ${height}`;
2785
- surface.style.maxInlineSize = "100%";
2786
- surface.style.border = "1px solid CanvasText";
2787
- surface.style.background = "Canvas";
2788
- surface.style.touchAction = "none";
2789
- const restoredStrokes = parseDrawingValue(currentValue);
2790
- const authoredBackgroundHref = drawingBackgroundHref(interaction);
2791
- let resolvedAuthoredBackgroundHref = authoredBackgroundHref;
2792
- let activeBackgroundIsAuthored = restoredStrokes.length > 0 || !drawingResponseImage(currentValue);
2793
- let activeBackgroundHref = restoredStrokes.length === 0
2794
- ? (drawingResponseImage(currentValue) ?? authoredBackgroundHref)
2795
- : authoredBackgroundHref;
2796
- const resetSurface = () => {
2797
- const background = activeBackgroundHref
2798
- ? drawingImageElement(activeBackgroundHref, width, height)
2799
- : undefined;
2800
- surface.replaceChildren(...(background ? [background] : []));
2801
- };
2802
- resetSurface();
2803
- const summary = document.createElement("output");
2804
- summary.className = "qti3-coordinate-output";
2805
- const strokes = [];
2806
- let activeStroke;
2807
- let commitVersion = 0;
2808
- const commit = (emitResponse = true) => {
2809
- const version = ++commitVersion;
2810
- if (emitResponse) {
2811
- if (strokes.length === 0) {
2812
- update(null);
2813
- }
2814
- else {
2815
- void exportDrawingResponse(interaction, width, height, strokes, () => {
2816
- const currentHref = currentDrawingBackgroundHref(surface);
2817
- if (activeBackgroundIsAuthored && currentHref) {
2818
- resolvedAuthoredBackgroundHref = currentHref;
2819
- }
2820
- return currentHref ?? activeBackgroundHref;
2821
- }).then((value) => {
2822
- if (version === commitVersion)
2823
- update(value);
2824
- });
2825
- }
2826
- }
2827
- const count = strokes.length;
2828
- summary.value = serializeDrawingStrokes(strokes);
2829
- summary.textContent =
2830
- count === 0 ? "No drawing strokes." : `${count} drawing stroke${count === 1 ? "" : "s"}.`;
2831
- surface.setAttribute("aria-label", count === 0
2832
- ? "Drawing response surface, no strokes"
2833
- : `Drawing response surface, ${count} stroke${count === 1 ? "" : "s"}`);
2834
- };
2835
- for (const points of restoredStrokes) {
2836
- const element = polylineElement(points);
2837
- strokes.push({ points, element });
2838
- surface.append(element);
2839
- }
2840
- const addPoint = (event) => {
2841
- if (!activeStroke)
2842
- return;
2843
- const point = svgPoint(surface, event);
2844
- const previous = activeStroke.points.at(-1);
2845
- if (previous && previous.x === point.x && previous.y === point.y)
2846
- return;
2847
- activeStroke.points.push(point);
2848
- activeStroke.element.setAttribute("points", serializeSvgPoints(activeStroke.points));
2849
- };
2850
- const finishStroke = (event) => {
2851
- if (!activeStroke)
2852
- return;
2853
- addPoint(event);
2854
- const firstPoint = activeStroke.points[0];
2855
- if (activeStroke.points.length === 1 && firstPoint)
2856
- activeStroke.points.push(firstPoint);
2857
- activeStroke.element.setAttribute("points", serializeSvgPoints(activeStroke.points));
2858
- activeStroke = undefined;
2859
- commit();
2860
- };
2861
- surface.addEventListener("pointerdown", (event) => {
2862
- const point = svgPoint(surface, event);
2863
- const element = polylineElement([point]);
2864
- activeStroke = { points: [point], element };
2865
- strokes.push(activeStroke);
2866
- surface.append(element);
2867
- surface.setPointerCapture(event.pointerId);
2868
- });
2869
- surface.addEventListener("pointermove", addPoint);
2870
- surface.addEventListener("pointerup", finishStroke);
2871
- surface.addEventListener("pointercancel", () => {
2872
- activeStroke = undefined;
2873
- });
2874
- surface.addEventListener("keydown", (event) => {
2875
- if (event.key !== "Enter" && event.key !== " ")
2876
- return;
2877
- event.preventDefault();
2878
- const points = [
2879
- { x: 10, y: 10 },
2880
- { x: 90, y: 90 },
2881
- ];
2882
- const element = polylineElement(points);
2883
- strokes.push({ points, element });
2884
- surface.append(element);
2885
- commit();
2886
- });
2887
- const clear = document.createElement("button");
2888
- clear.type = "button";
2889
- clear.textContent = "Clear drawing";
2890
- clear.addEventListener("click", () => {
2891
- strokes.splice(0, strokes.length);
2892
- activeStroke = undefined;
2893
- if (activeBackgroundIsAuthored) {
2894
- resolvedAuthoredBackgroundHref =
2895
- currentDrawingBackgroundHref(surface) ?? resolvedAuthoredBackgroundHref;
2896
- }
2897
- activeBackgroundHref = resolvedAuthoredBackgroundHref;
2898
- activeBackgroundIsAuthored = true;
2899
- resetSurface();
2900
- commit();
2901
- });
2902
- const tools = document.createElement("div");
2903
- tools.className = "qti3-drawing-tools";
2904
- tools.append(clear);
2905
- commit(false);
2906
- group.append(surface, summary, tools);
2907
- return group;
2908
- }
2909
- function renderHotspotResponse(interaction, update, currentValue) {
2910
- const group = responseGroup();
2911
- const surface = document.createElement("div");
2912
- surface.className = "qti3-hotspot-surface";
2913
- const width = objectWidth(interaction);
2914
- const height = objectHeight(interaction);
2915
- surface.style.position = "relative";
2916
- surface.style.inlineSize = `${width}px`;
2917
- surface.style.aspectRatio = `${width} / ${height}`;
2918
- surface.style.maxInlineSize = "100%";
2919
- surface.style.border = "1px solid CanvasText";
2920
- surface.style.background = "Canvas";
2921
- surface.style.overflow = "hidden";
2922
- const object = interaction.object;
2923
- if (object?.data && object.type?.startsWith("image/")) {
2924
- const image = document.createElement("img");
2925
- image.src = object.data;
2926
- image.alt = object.text || `${readableType(interaction.type)} image`;
2927
- image.style.inlineSize = "100%";
2928
- image.style.blockSize = "100%";
2929
- image.style.objectFit = "contain";
2930
- surface.append(image);
2931
- }
2932
- const selected = new Set(valueToStrings(currentValue));
2933
- const multiple = interaction.responseCardinality === "multiple";
2934
- const selectedSummary = document.createElement("p");
2935
- selectedSummary.className = "qti3-selection-summary";
2936
- selectedSummary.setAttribute("aria-live", "polite");
2937
- selectedSummary.textContent = "No region selected";
2938
- const syncSelected = () => {
2939
- for (const button of surface.querySelectorAll("button")) {
2940
- const isSelected = selected.has(button.dataset.choiceIdentifier ?? "");
2941
- button.setAttribute("aria-pressed", isSelected ? "true" : "false");
2942
- button.dataset.selected = isSelected ? "true" : "false";
2943
- }
2944
- selectedSummary.textContent =
2945
- selected.size > 0 ? `Selected ${[...selected].join(", ")}` : "No region selected";
2946
- };
2947
- for (const choice of choicesOrFallback(interaction)) {
2948
- const button = document.createElement("button");
2949
- button.type = "button";
2950
- button.className = "qti3-hotspot-button";
2951
- button.dataset.choiceIdentifier = choice.identifier;
2952
- button.textContent = choice.text;
2953
- button.title = choice.text;
2954
- button.setAttribute("aria-pressed", "false");
2955
- button.style.position = "absolute";
2956
- placeHotspotButton(button, choice, width, height);
2957
- button.addEventListener("click", () => {
2958
- if (multiple) {
2959
- if (selected.has(choice.identifier))
2960
- selected.delete(choice.identifier);
2961
- else
2962
- selected.add(choice.identifier);
2963
- syncSelected();
2964
- update([...selected]);
2965
- }
2966
- else {
2967
- selected.clear();
2968
- selected.add(choice.identifier);
2969
- syncSelected();
2970
- update(choice.identifier);
2971
- }
2972
- });
2973
- surface.append(button);
2974
- }
2975
- syncSelected();
2976
- group.append(surface, selectedSummary);
2977
- return group;
2978
- }
2979
- function renderObjectAsset(interaction, mediaResponse = {}) {
2980
- const object = interaction.object;
2981
- const label = interaction.prompt ?? object?.text ?? "Media interaction";
2982
- const mediaType = object ? mediaElementType(object) : undefined;
2983
- if (object && mediaType === "audio") {
2984
- const audio = document.createElement("audio");
2985
- configureMediaElement(audio, interaction, object, label, mediaResponse);
2986
- audio.style.inlineSize = "100%";
2987
- return audio;
2988
- }
2989
- if (object && mediaType === "video") {
2990
- const video = document.createElement("video");
2991
- configureMediaElement(video, interaction, object, label, mediaResponse);
2992
- if (object.width)
2993
- video.width = Number(object.width);
2994
- if (object.height)
2995
- video.height = Number(object.height);
2996
- return video;
2997
- }
2998
- if (object?.data && objectIsImage(object)) {
2999
- const image = document.createElement("img");
3000
- image.src = object.data;
3001
- image.alt = label;
3002
- image.style.maxInlineSize = "100%";
3003
- image.style.blockSize = "auto";
3004
- if (object.width)
3005
- image.width = Number(object.width);
3006
- if (object.height)
3007
- image.height = Number(object.height);
3008
- return image;
3009
- }
3010
- const group = document.createElement("div");
3011
- group.role = "group";
3012
- group.setAttribute("aria-label", label);
3013
- const fallbackHref = object?.data ?? object?.sources.find((source) => source.src)?.src;
3014
- if (fallbackHref) {
3015
- const link = document.createElement("a");
3016
- link.href = fallbackHref;
3017
- link.textContent = object?.text || fallbackHref;
3018
- group.append(link);
3019
- }
3020
- else {
3021
- group.textContent = label;
3022
- }
3023
- return group;
3024
- }
3025
- function configureMediaElement(media, interaction, object, label, mediaResponse) {
3026
- media.controls = mediaControlsMode(interaction, object) !== "none";
3027
- media.preload = "none";
3028
- media.autoplay = parseBooleanAttribute(interaction.attributes.autostart) ?? false;
3029
- media.loop = parseBooleanAttribute(interaction.attributes.loop) ?? false;
3030
- media.setAttribute("aria-label", label);
3031
- media.style.maxInlineSize = "100%";
3032
- copyMediaDataAttributes(media, interaction.attributes);
3033
- copyMediaDataAttributes(media, object.attributes);
3034
- if (object.data)
3035
- media.src = object.data;
3036
- for (const source of object.sources) {
3037
- if (!source.src)
3038
- continue;
3039
- const sourceElement = document.createElement("source");
3040
- sourceElement.src = source.src;
3041
- if (source.type)
3042
- sourceElement.type = source.type;
3043
- copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
3044
- media.append(sourceElement);
3045
- }
3046
- for (const track of object.tracks) {
3047
- if (!track.src)
3048
- continue;
3049
- const trackElement = document.createElement("track");
3050
- trackElement.src = track.src;
3051
- if (track.kind)
3052
- trackElement.kind = track.kind;
3053
- if (track.srclang)
3054
- trackElement.srclang = track.srclang;
3055
- if (track.label)
3056
- trackElement.label = track.label;
3057
- if (track.default)
3058
- trackElement.default = true;
3059
- copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
3060
- media.append(trackElement);
3061
- }
3062
- bindMediaPlayCount(media, interaction, mediaResponse);
3063
- }
3064
- function copyMediaDataAttributes(element, attributes) {
3065
- for (const [name, value] of Object.entries(attributes)) {
3066
- if (!name.startsWith("data-"))
3067
- continue;
3068
- element.setAttribute(name, value);
3069
- }
3070
- }
3071
- const sourceAttributeNames = new Set(["src", "srcset", "type"]);
3072
- const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
3073
- function copySafeMediaChildAttributes(element, attributes, controlledNames) {
3074
- for (const [name, value] of Object.entries(attributes)) {
3075
- const normalizedName = name.toLowerCase();
3076
- if (controlledNames.has(normalizedName))
3077
- continue;
3078
- if (normalizedName === "class" ||
3079
- normalizedName === "id" ||
3080
- normalizedName === "title" ||
3081
- normalizedName === "media" ||
3082
- normalizedName === "sizes" ||
3083
- normalizedName.startsWith("data-")) {
3084
- element.setAttribute(name, value);
3085
- }
3086
- }
3087
- }
3088
- function mediaElementType(object) {
3089
- const types = [object.type, ...object.sources.map((source) => source.type)].filter((value) => Boolean(value));
3090
- if (types.some((value) => value.startsWith("audio/")))
3091
- return "audio";
3092
- if (types.some((value) => value.startsWith("video/")))
3093
- return "video";
3094
- return undefined;
3095
- }
3096
- function mediaControlsMode(interaction, object) {
3097
- return (interaction.attributes["data-qti-media-player-controls"] ??
3098
- object.attributes["data-qti-media-player-controls"]);
3099
- }
3100
- function bindMediaPlayCount(media, interaction, mediaResponse) {
3101
- if (!mediaResponse.update)
3102
- return;
3103
- let playCount = mediaPlayCount(mediaResponse.currentValue ?? null);
3104
- let activePlaySession = false;
3105
- let readyAfterEnded = false;
3106
- const maximum = maximumMediaPlays(interaction);
3107
- const syncState = () => {
3108
- media.dataset.playCount = String(playCount);
3109
- if (maximum !== undefined && playCount >= maximum && !activePlaySession) {
3110
- media.dataset.maxPlaysReached = "true";
3111
- }
3112
- else {
3113
- delete media.dataset.maxPlaysReached;
3114
- }
3115
- };
3116
- media.addEventListener("play", () => {
3117
- if (mediaResponse.isCompleted?.()) {
3118
- return;
3119
- }
3120
- if (!activePlaySession && maximum !== undefined && playCount >= maximum) {
3121
- media.pause();
3122
- syncState();
3123
- return;
3124
- }
3125
- if (!activePlaySession && (readyAfterEnded || media.currentTime <= 0.25)) {
3126
- playCount += 1;
3127
- mediaResponse.update?.(playCount);
3128
- activePlaySession = true;
3129
- readyAfterEnded = false;
3130
- syncState();
3131
- return;
3132
- }
3133
- activePlaySession = true;
3134
- readyAfterEnded = false;
3135
- syncState();
3136
- });
3137
- media.addEventListener("ended", () => {
3138
- activePlaySession = false;
3139
- readyAfterEnded = true;
3140
- syncState();
3141
- });
3142
- media.addEventListener("seeked", () => {
3143
- if (!media.paused || media.currentTime > 0.25)
3144
- return;
3145
- activePlaySession = false;
3146
- readyAfterEnded = false;
3147
- syncState();
3148
- });
3149
- syncState();
3150
- }
3151
- function objectIsImage(object) {
3152
- return Boolean(object.type?.startsWith("image/") ||
3153
- object.data?.startsWith("data:image/") ||
3154
- /\.(svg|png|jpg|jpeg|gif|webp)(?:[?#].*)?$/i.test(object.data ?? ""));
3155
- }
3156
- function appendOptions(select, choices) {
3157
- const empty = document.createElement("option");
3158
- empty.value = "";
3159
- empty.textContent = "";
3160
- select.append(empty);
3161
- for (const choice of choices) {
3162
- const option = document.createElement("option");
3163
- option.value = choice.identifier;
3164
- option.textContent = choice.text;
3165
- select.append(option);
3166
- }
3167
- }
3168
- function tokenRegion(label, visibleLabel) {
3169
- const region = document.createElement("div");
3170
- region.className = "qti3-token-region";
3171
- region.role = "group";
3172
- region.setAttribute("aria-label", label);
3173
- if (visibleLabel) {
3174
- const heading = document.createElement("strong");
3175
- heading.className = "qti3-region-label";
3176
- heading.textContent = visibleLabel;
3177
- region.append(heading);
3178
- }
3179
- return region;
3180
- }
3181
- function tokenButton(choice) {
3182
- const button = document.createElement("button");
3183
- button.type = "button";
3184
- button.className = "qti3-token";
3185
- button.dataset.choiceIdentifier = choice.identifier;
3186
- button.setAttribute("aria-pressed", "false");
3187
- button.textContent = choice.text;
3188
- return button;
3189
- }
3190
- function choiceText(choices, identifier) {
3191
- if (!identifier)
3192
- return "";
3193
- return choices.find((choice) => choice.identifier === identifier)?.text ?? identifier;
3194
- }
3195
- function sourceChoices(interaction) {
3196
- const choices = choicesOrFallback(interaction);
3197
- if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
3198
- const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
3199
- return gapChoices.length > 0 ? gapChoices : choices;
3200
- }
3201
- const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
3202
- const sources = choices.filter((choice) => sourceRoles.has(choice.role));
3203
- return sources.length > 0 ? sources : choices;
3204
- }
3205
- function targetChoices(interaction) {
3206
- const choices = choicesOrFallback(interaction);
3207
- if (interaction.type === "associate" || interaction.type === "graphicAssociate")
3208
- return choices;
3209
- const targetRoles = new Set(["matchTarget", "gap", "hotspot"]);
3210
- const targets = choices.filter((choice) => targetRoles.has(choice.role));
3211
- return targets.length > 0 ? targets : choices;
3212
- }
3213
- function choicesOrFallback(interaction) {
3214
- if (interaction.choices.length > 0)
3215
- return interaction.choices;
3216
- return [
3217
- {
3218
- identifier: "A",
3219
- text: "A",
3220
- role: "simpleChoice",
3221
- qtiName: "qti-simple-choice",
3222
- attributes: {},
3223
- },
3224
- {
3225
- identifier: "B",
3226
- text: "B",
3227
- role: "simpleChoice",
3228
- qtiName: "qti-simple-choice",
3229
- attributes: {},
3230
- },
3231
- ];
3232
- }
3233
- function valueToStrings(value) {
3234
- if (value === null)
3235
- return [];
3236
- if (Array.isArray(value))
3237
- return value.map((item) => String(item));
3238
- return [String(value)];
3239
- }
3240
- function scalarString(value) {
3241
- if (value === null || Array.isArray(value) || typeof value === "object")
3242
- return "";
3243
- return String(value);
3244
- }
3245
- function coerceResponseInputValue(value, baseType) {
3246
- if (baseType === "integer")
3247
- return Number.parseInt(value, 10);
3248
- if (baseType === "float")
3249
- return Number.parseFloat(value);
3250
- if (baseType === "boolean") {
3251
- if (value === "true")
3252
- return true;
3253
- if (value === "false")
3254
- return false;
3255
- }
3256
- return value;
3257
- }
3258
- function orderChoicesFromValue(choices, value) {
3259
- const identifiers = valueToStrings(value);
3260
- if (identifiers.length === 0)
3261
- return [...choices];
3262
- const byIdentifier = new Map(choices.map((choice) => [choice.identifier, choice]));
3263
- const ordered = identifiers
3264
- .map((identifier) => byIdentifier.get(identifier))
3265
- .filter((choice) => Boolean(choice));
3266
- const used = new Set(ordered.map((choice) => choice.identifier));
3267
- ordered.push(...choices.filter((choice) => !used.has(choice.identifier)));
3268
- return ordered;
3269
- }
3270
- function parsePointValue(value) {
3271
- const [raw] = valueToStrings(value);
3272
- return parsePointString(raw);
3273
- }
3274
- function parsePointValues(value) {
3275
- return valueToStrings(value).flatMap((raw) => {
3276
- const point = parsePointString(raw);
3277
- return point ? [point] : [];
3278
- });
3279
- }
3280
- function parsePointString(raw) {
3281
- if (!raw)
3282
- return undefined;
3283
- const values = raw.split(/\s+/).map(Number);
3284
- const x = values[0];
3285
- const y = values[1];
3286
- if (typeof x !== "number" || typeof y !== "number")
3287
- return undefined;
3288
- if (!Number.isFinite(x) || !Number.isFinite(y))
3289
- return undefined;
3290
- return { x, y };
3291
- }
3292
- function pointToString(point) {
3293
- return point ? `${point.x} ${point.y}` : "";
3294
- }
3295
- function parseDrawingValue(value) {
3296
- const raw = scalarString(value);
3297
- if (!raw)
3298
- return [];
3299
- const metadata = drawingMetadataFromSvgDataUrl(raw);
3300
- if (metadata)
3301
- return parseDrawingStrokePayload(metadata);
3302
- return parseDrawingStrokePayload(raw);
3303
- }
3304
- function drawingMetadataFromSvgDataUrl(raw) {
3305
- if (!raw.startsWith("data:image/svg+xml"))
3306
- return undefined;
3307
- const commaIndex = raw.indexOf(",");
3308
- if (commaIndex === -1)
3309
- return undefined;
3310
- const encoded = raw.slice(commaIndex + 1);
3311
- let svg = "";
3312
- try {
3313
- svg = raw.slice(0, commaIndex).includes(";base64")
3314
- ? atob(encoded)
3315
- : decodeURIComponent(encoded);
3316
- }
3317
- catch {
3318
- return undefined;
3319
- }
3320
- const match = svg.match(/\sdata-qti3-strokes="([^"]*)"/);
3321
- if (!match?.[1])
3322
- return undefined;
3323
- try {
3324
- return decodeURIComponent(match[1]);
3325
- }
3326
- catch {
3327
- return undefined;
3328
- }
3329
- }
3330
- function drawingResponseImage(value) {
3331
- const raw = scalarString(value);
3332
- return raw?.startsWith("data:image/") ? raw : undefined;
3333
- }
3334
- function parseDrawingStrokePayload(raw) {
3335
- return raw
3336
- .split("|")
3337
- .map((stroke) => {
3338
- const numbers = stroke
3339
- .trim()
3340
- .split(/\s+/)
3341
- .map(Number)
3342
- .filter((item) => Number.isFinite(item));
3343
- const points = [];
3344
- for (let index = 0; index + 1 < numbers.length; index += 2) {
3345
- points.push({ x: numbers[index], y: numbers[index + 1] });
3346
- }
3347
- return points;
3348
- })
3349
- .filter((points) => points.length > 0);
3350
- }
3351
- function objectWidth(interaction) {
3352
- return dimension(interaction.object?.width, 160);
3353
- }
3354
- function objectHeight(interaction) {
3355
- return dimension(interaction.object?.height, 120);
3356
- }
3357
- function objectAssetWidth(object, fallback) {
3358
- return dimension(object?.width, fallback);
3359
- }
3360
- function objectAssetHeight(object, fallback) {
3361
- return dimension(object?.height, fallback);
3362
- }
3363
- function drawingWidth(interaction) {
3364
- return dimension(interaction.object?.width, 640);
3365
- }
3366
- function drawingHeight(interaction) {
3367
- return dimension(interaction.object?.height, 360);
3368
- }
3369
- function dimension(value, fallback) {
3370
- const parsed = Number(value);
3371
- return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
3372
- }
3373
- function positivePixelValue(value) {
3374
- const parsed = Number(value);
3375
- return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
3376
- }
3377
- function graphicGapLabelBlockSize(sources) {
3378
- const maxLength = Math.max(0, ...sources.map((source) => (source.text || source.identifier).trim().length));
3379
- const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
3380
- return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
3381
- }
3382
- function placeHotspotButton(button, choice, width, height) {
3383
- const coords = (choice.attributes.coords ?? "")
3384
- .split(",")
3385
- .map((value) => Number(value.trim()))
3386
- .filter((value) => Number.isFinite(value));
3387
- const shape = choice.attributes.shape;
3388
- if (shape === "circle" && coords.length >= 3) {
3389
- const [x, y, radius] = coords;
3390
- button.style.insetInlineStart = `${percent(x - radius, width)}%`;
3391
- button.style.insetBlockStart = `${percent(y - radius, height)}%`;
3392
- button.style.inlineSize = `${percent(radius * 2, width)}%`;
3393
- button.style.blockSize = `${percent(radius * 2, height)}%`;
3394
- button.style.borderRadius = "50%";
3395
- return;
3396
- }
3397
- if (shape === "rect" && coords.length >= 4) {
3398
- const [left, top, right, bottom] = coords;
3399
- button.style.insetInlineStart = `${percent(left, width)}%`;
3400
- button.style.insetBlockStart = `${percent(top, height)}%`;
3401
- button.style.inlineSize = `${percent(Math.max(1, right - left), width)}%`;
3402
- button.style.blockSize = `${percent(Math.max(1, bottom - top), height)}%`;
3403
- return;
3404
- }
3405
- if (shape === "poly" && coords.length >= 6) {
3406
- const xs = coords.filter((_, index) => index % 2 === 0);
3407
- const ys = coords.filter((_, index) => index % 2 === 1);
3408
- const left = Math.min(...xs);
3409
- const top = Math.min(...ys);
3410
- const right = Math.max(...xs);
3411
- const bottom = Math.max(...ys);
3412
- button.style.insetInlineStart = `${percent(left, width)}%`;
3413
- button.style.insetBlockStart = `${percent(top, height)}%`;
3414
- button.style.inlineSize = `${percent(Math.max(1, right - left), width)}%`;
3415
- button.style.blockSize = `${percent(Math.max(1, bottom - top), height)}%`;
3416
- return;
3417
- }
3418
- button.style.insetInlineStart = "0";
3419
- button.style.insetBlockStart = "0";
3420
- }
3421
- function hotspotCenter(choice, width, height) {
3422
- const coords = hotspotCoords(choice);
3423
- const shape = choice.attributes.shape;
3424
- if ((shape === "circle" || shape === "ellipse") && coords.length >= 2) {
3425
- const [x, y] = coords;
3426
- return { x, y };
3427
- }
3428
- if (shape === "rect" && coords.length >= 4) {
3429
- const [left, top, right, bottom] = coords;
3430
- return { x: (left + right) / 2, y: (top + bottom) / 2 };
3431
- }
3432
- if (shape === "poly" && coords.length >= 6) {
3433
- const xs = coords.filter((_, index) => index % 2 === 0);
3434
- const ys = coords.filter((_, index) => index % 2 === 1);
3435
- return {
3436
- x: (Math.min(...xs) + Math.max(...xs)) / 2,
3437
- y: (Math.min(...ys) + Math.max(...ys)) / 2,
3438
- };
3439
- }
3440
- return { x: width / 2, y: height / 2 };
3441
- }
3442
- function hotspotCoords(choice) {
3443
- return (choice.attributes.coords ?? "")
3444
- .split(",")
3445
- .map((value) => Number(value.trim()))
3446
- .filter((value) => Number.isFinite(value));
3447
- }
3448
- function hotspotDisplayLabel(choice, choices) {
3449
- return choice.attributes["hotspot-label"] || `Region ${choices.indexOf(choice) + 1}`;
3450
- }
3451
- function hotspotAccessibleLabel(choice, index) {
3452
- return (choice.attributes["aria-label"] || choice.attributes["hotspot-label"] || `Region ${index + 1}`);
3453
- }
3454
- function exceedsHotspotMatchMax(choice, selectedPairs) {
3455
- const maximum = parseUnlimitedMaximum(choice.attributes["match-max"]);
3456
- if (maximum === undefined)
3457
- return false;
3458
- const currentUseCount = selectedPairs
3459
- .flatMap((pair) => pair.split(" "))
3460
- .filter((identifier) => identifier === choice.identifier).length;
3461
- return currentUseCount + 1 > maximum;
3462
- }
3463
- function percent(value, total) {
3464
- if (total <= 0)
3465
- return 0;
3466
- return (value / total) * 100;
3467
- }
3468
- function svgPoint(surface, event) {
3469
- const rect = surface.getBoundingClientRect();
3470
- const viewBox = surface.viewBox.baseVal;
3471
- const width = viewBox.width || 160;
3472
- const height = viewBox.height || 120;
3473
- const x = Math.round(((event.clientX - rect.left) / rect.width) * width);
3474
- const y = Math.round(((event.clientY - rect.top) / rect.height) * height);
3475
- return {
3476
- x: Math.max(0, Math.min(width, x)),
3477
- y: Math.max(0, Math.min(height, y)),
3478
- };
3479
- }
3480
- async function exportDrawingResponse(interaction, width, height, strokes, backgroundHref) {
3481
- const href = backgroundHref();
3482
- const mime = drawingResponseMime(interaction.object);
3483
- if (mime === "image/svg+xml") {
3484
- return svgDrawingDataUrl(interaction, width, height, strokes, await portableImageHref(href));
3485
- }
3486
- return rasterDrawingDataUrl(interaction, width, height, strokes, href, mime);
3487
- }
3488
- function drawingResponseMime(object) {
3489
- const candidates = [
3490
- object?.type,
3491
- object?.data,
3492
- ...(object?.sources.map((source) => source.type ?? source.src) ?? []),
3493
- ];
3494
- for (const candidate of candidates) {
3495
- const mime = imageMime(candidate);
3496
- if (mime)
3497
- return mime;
3498
- }
3499
- return "image/svg+xml";
3500
- }
3501
- function imageMime(value) {
3502
- if (!value)
3503
- return undefined;
3504
- const normalized = value.toLowerCase().split(";")[0] ?? "";
3505
- if (normalized === "image/svg+xml")
3506
- return "image/svg+xml";
3507
- if (normalized === "image/png")
3508
- return "image/png";
3509
- if (normalized === "image/jpeg" || normalized === "image/jpg")
3510
- return "image/jpeg";
3511
- if (normalized === "image/webp")
3512
- return "image/webp";
3513
- const dataMime = value.match(/^data:([^;,]+)/i)?.[1]?.toLowerCase();
3514
- if (dataMime)
3515
- return imageMime(dataMime);
3516
- if (/\.svg(?:[?#].*)?$/i.test(value))
3517
- return "image/svg+xml";
3518
- if (/\.png(?:[?#].*)?$/i.test(value))
3519
- return "image/png";
3520
- if (/\.jpe?g(?:[?#].*)?$/i.test(value))
3521
- return "image/jpeg";
3522
- if (/\.webp(?:[?#].*)?$/i.test(value))
3523
- return "image/webp";
3524
- return undefined;
3525
- }
3526
- function svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref) {
3527
- const markup = svgDrawingMarkup(interaction, width, height, strokes, backgroundHref);
3528
- return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
3529
- }
3530
- function svgDrawingMarkup(interaction, width, height, strokes, backgroundHref) {
3531
- const strokePayload = serializeDrawingStrokes(strokes);
3532
- const background = backgroundHref && interaction.object && objectIsImage(interaction.object)
3533
- ? `<image href="${xmlAttribute(backgroundHref)}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>`
3534
- : "";
3535
- const lines = strokes
3536
- .map((stroke) => {
3537
- return `<polyline points="${xmlAttribute(serializeSvgPoints(stroke.points))}" fill="none" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`;
3538
- })
3539
- .join("");
3540
- return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}"><metadata id="qti3-drawing-response" data-qti3-strokes="${xmlAttribute(encodeURIComponent(strokePayload))}"></metadata>${background}${lines}</svg>`;
3541
- }
3542
- async function rasterDrawingDataUrl(interaction, width, height, strokes, backgroundHref, mime) {
3543
- const canvas = document.createElement("canvas");
3544
- canvas.width = width;
3545
- canvas.height = height;
3546
- const context = canvas.getContext("2d");
3547
- if (!context)
3548
- return svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref);
3549
- if (mime === "image/jpeg") {
3550
- context.fillStyle = "#fff";
3551
- context.fillRect(0, 0, width, height);
3552
- }
3553
- if (backgroundHref && interaction.object && objectIsImage(interaction.object)) {
3554
- try {
3555
- const image = await loadCanvasImage(backgroundHref);
3556
- context.drawImage(image, 0, 0, width, height);
3557
- }
3558
- catch {
3559
- // Export the candidate marks even when the authored background cannot be rasterized.
3560
- }
3561
- }
3562
- context.strokeStyle = "#000";
3563
- context.lineWidth = 3;
3564
- context.lineCap = "round";
3565
- context.lineJoin = "round";
3566
- for (const stroke of strokes) {
3567
- const [first, ...rest] = stroke.points;
3568
- if (!first)
3569
- continue;
3570
- context.beginPath();
3571
- context.moveTo(first.x, first.y);
3572
- for (const point of rest)
3573
- context.lineTo(point.x, point.y);
3574
- context.stroke();
3575
- }
3576
- try {
3577
- return canvas.toDataURL(mime);
3578
- }
3579
- catch {
3580
- return svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref);
3581
- }
3582
- }
3583
- function loadCanvasImage(src) {
3584
- return new Promise((resolve, reject) => {
3585
- const image = new Image();
3586
- image.addEventListener("load", () => resolve(image), { once: true });
3587
- image.addEventListener("error", () => reject(new Error(`Unable to load ${src}`)), {
3588
- once: true,
3589
- });
3590
- image.src = src;
3591
- });
3592
- }
3593
- async function portableImageHref(href) {
3594
- if (!href || href.startsWith("data:"))
3595
- return href;
3596
- try {
3597
- const response = await fetch(href);
3598
- if (!response.ok)
3599
- return href;
3600
- return await blobToDataUrl(await response.blob());
3601
- }
3602
- catch {
3603
- return href;
3604
- }
3605
- }
3606
- function blobToDataUrl(blob) {
3607
- return new Promise((resolve, reject) => {
3608
- const reader = new FileReader();
3609
- reader.addEventListener("load", () => {
3610
- resolve(String(reader.result ?? ""));
3611
- });
3612
- reader.addEventListener("error", () => {
3613
- reject(reader.error ?? new Error("Unable to read drawing background."));
3614
- });
3615
- reader.readAsDataURL(blob);
3616
- });
3617
- }
3618
- function drawingBackgroundHref(interaction) {
3619
- if (!interaction.object?.data || !objectIsImage(interaction.object))
3620
- return undefined;
3621
- return interaction.object.data;
3622
- }
3623
- function drawingImageElement(href, width, height) {
3624
- const image = document.createElementNS("http://www.w3.org/2000/svg", "image");
3625
- image.setAttribute("href", href);
3626
- image.setAttribute("width", String(width));
3627
- image.setAttribute("height", String(height));
3628
- image.setAttribute("preserveAspectRatio", "xMidYMid meet");
3629
- image.setAttribute("aria-hidden", "true");
3630
- return image;
3631
- }
3632
- function currentDrawingBackgroundHref(surface) {
3633
- return surface.querySelector("image")?.getAttribute("href") ?? undefined;
3634
- }
3635
- function serializeSvgPoints(points) {
3636
- return points.map((point) => `${point.x},${point.y}`).join(" ");
3637
- }
3638
- function serializeDrawingStrokes(strokes) {
3639
- return strokes.map((stroke) => serializeDrawingStroke(stroke.points)).join(" | ");
3640
- }
3641
- function serializeDrawingStroke(points) {
3642
- return points.map((point) => `${point.x} ${point.y}`).join(" ");
3643
- }
3644
- function xmlAttribute(value) {
3645
- return value
3646
- .replaceAll("&", "&amp;")
3647
- .replaceAll('"', "&quot;")
3648
- .replaceAll("<", "&lt;")
3649
- .replaceAll(">", "&gt;");
3650
- }
3651
- function polylineElement(points) {
3652
- const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
3653
- line.setAttribute("points", serializeSvgPoints(points));
3654
- line.setAttribute("fill", "none");
3655
- line.setAttribute("stroke", "CanvasText");
3656
- line.setAttribute("stroke-width", "3");
3657
- line.setAttribute("stroke-linecap", "round");
3658
- line.setAttribute("stroke-linejoin", "round");
3659
- return line;
3660
- }
3661
- function portableCustomDefinitionFromAttributes(interaction) {
3662
- return {
3663
- responseIdentifier: interaction.responseIdentifier,
3664
- customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
3665
- module: interaction.attributes.module,
3666
- interactionMarkup: [],
3667
- templateVariables: [],
3668
- contextVariables: [],
3669
- stylesheets: [],
3670
- dataAttributes: Object.fromEntries(Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-"))),
3671
- attributes: interaction.attributes,
3672
- source: interaction.source,
3673
- };
3674
- }
3675
- function portableCustomEventValue(event) {
3676
- if (!("detail" in event))
3677
- return undefined;
3678
- const detail = event.detail;
3679
- if (detail === undefined)
3680
- return undefined;
3681
- if (typeof detail === "object" && detail !== null && !Array.isArray(detail)) {
3682
- if ("value" in detail)
3683
- return detail.value ?? null;
3684
- if ("response" in detail)
3685
- return detail.response ?? null;
3686
- if ("state" in detail || "valid" in detail)
3687
- return undefined;
3688
- }
3689
- return detail;
3690
- }
3691
- function portableCustomEventState(event) {
3692
- if (!("detail" in event))
3693
- return undefined;
3694
- const detail = event.detail;
3695
- if (typeof detail !== "object" || detail === null || !("state" in detail))
3696
- return undefined;
3697
- return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
3698
- }
3699
- function portableCustomEventValidity(event) {
3700
- if (!("detail" in event))
3701
- return undefined;
3702
- const detail = event.detail;
3703
- if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
3704
- return undefined;
3705
- }
3706
- return {
3707
- valid: detail.valid,
3708
- message: typeof detail.message === "string" ? detail.message : undefined,
3709
- };
3710
- }
3711
- function isPortableCustomStateValue(value) {
3712
- if (value === null)
3713
- return true;
3714
- if (typeof value === "string" || typeof value === "boolean")
3715
- return true;
3716
- if (typeof value === "number")
3717
- return Number.isFinite(value);
3718
- if (Array.isArray(value))
3719
- return value.every(isPortableCustomStateValue);
3720
- if (typeof value === "object") {
3721
- return Object.values(value).every(isPortableCustomStateValue);
3722
- }
3723
- return false;
3724
- }
3725
- const htmlContentElements = new Set([
3726
- "a",
3727
- "abbr",
3728
- "b",
3729
- "bdi",
3730
- "bdo",
3731
- "blockquote",
3732
- "br",
3733
- "caption",
3734
- "cite",
3735
- "code",
3736
- "dd",
3737
- "dfn",
3738
- "div",
3739
- "dl",
3740
- "dt",
3741
- "em",
3742
- "figcaption",
3743
- "figure",
3744
- "h1",
3745
- "h2",
3746
- "h3",
3747
- "h4",
3748
- "h5",
3749
- "h6",
3750
- "hr",
3751
- "i",
3752
- "img",
3753
- "kbd",
3754
- "li",
3755
- "ol",
3756
- "p",
3757
- "pre",
3758
- "q",
3759
- "rb",
3760
- "rbc",
3761
- "rp",
3762
- "rt",
3763
- "rtc",
3764
- "ruby",
3765
- "samp",
3766
- "small",
3767
- "span",
3768
- "strong",
3769
- "sub",
3770
- "sup",
3771
- "table",
3772
- "tbody",
3773
- "td",
3774
- "tfoot",
3775
- "th",
3776
- "thead",
3777
- "tr",
3778
- "ul",
3779
- "var",
3780
- ]);
3781
- const unsafeContentElements = new Set(["script", "style"]);
3782
- const mathMlElements = new Set([
3783
- "math",
3784
- "maction",
3785
- "maligngroup",
3786
- "malignmark",
3787
- "menclose",
3788
- "merror",
3789
- "mfenced",
3790
- "mfrac",
3791
- "mglyph",
3792
- "mi",
3793
- "mlabeledtr",
3794
- "mlongdiv",
3795
- "mmultiscripts",
3796
- "mn",
3797
- "mo",
3798
- "mover",
3799
- "mpadded",
3800
- "mphantom",
3801
- "mroot",
3802
- "mrow",
3803
- "ms",
3804
- "mscarries",
3805
- "mscarry",
3806
- "msgroup",
3807
- "msline",
3808
- "mspace",
3809
- "msqrt",
3810
- "msrow",
3811
- "mstack",
3812
- "mstyle",
3813
- "msub",
3814
- "msubsup",
3815
- "msup",
3816
- "mtable",
3817
- "mtd",
3818
- "mtext",
3819
- "mtr",
3820
- "munder",
3821
- "munderover",
3822
- "semantics",
3823
- ]);
3824
- function contentElementName(qtiName) {
3825
- if (qtiName === "qti-content-body" || qtiName === "qti-prompt")
3826
- return undefined;
3827
- if (htmlContentElements.has(qtiName) || mathMlElements.has(qtiName))
3828
- return qtiName;
3829
- if (qtiName === "object")
3830
- return "object";
3831
- if (qtiName === "qti-rubric-block")
3832
- return "section";
3833
- if (qtiName === "qti-template-block")
3834
- return "div";
3835
- if (qtiName === "qti-template-inline")
3836
- return "span";
3837
- return undefined;
3838
- }
3839
- function createContentElement(name) {
3840
- if (mathMlElements.has(name)) {
3841
- return document.createElementNS("http://www.w3.org/1998/Math/MathML", name);
3842
- }
3843
- return document.createElement(name);
3844
- }
3845
- function copySafeAttributes(element, attributes) {
3846
- for (const [name, value] of Object.entries(attributes)) {
3847
- if (!isSafeContentAttribute(name, value))
3848
- continue;
3849
- element.setAttribute(name, value);
3850
- if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
3851
- element.setAttribute("lang", value);
3852
- }
3853
- }
3854
- applySharedAccessibilityVocabulary(element, attributes);
3855
- }
3856
- function applySharedAccessibilityVocabulary(element, attributes) {
3857
- for (const [name, value] of Object.entries(attributes)) {
3858
- const ariaName = qtiAriaAttributeName(name);
3859
- if (!ariaName || hasAttributeName(attributes, ariaName))
3860
- continue;
3861
- element.setAttribute(ariaName, value);
3862
- }
3863
- const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
3864
- if (suppressesScreenReaderSpeech(suppressTts) &&
3865
- !hasAttributeName(attributes, "aria-hidden") &&
3866
- !hasAttributeName(attributes, "data-qti-aria-hidden")) {
3867
- element.setAttribute("aria-hidden", "true");
3868
- }
3869
- }
3870
- function qtiAriaAttributeName(name) {
3871
- const normalizedName = name.toLowerCase();
3872
- const prefix = "data-qti-aria-";
3873
- if (!normalizedName.startsWith(prefix))
3874
- return undefined;
3875
- const suffix = normalizedName.slice(prefix.length);
3876
- if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix))
3877
- return undefined;
3878
- return `aria-${suffix}`;
3879
- }
3880
- function attributeValue(attributes, name) {
3881
- const normalizedName = name.toLowerCase();
3882
- const entry = Object.entries(attributes).find(([attributeName]) => attributeName.toLowerCase() === normalizedName);
3883
- return entry?.[1];
3884
- }
3885
- function hasAttributeName(attributes, name) {
3886
- return attributeValue(attributes, name) !== undefined;
3887
- }
3888
- function suppressesScreenReaderSpeech(value) {
3889
- if (!value)
3890
- return false;
3891
- const tokens = value
3892
- .toLowerCase()
3893
- .split(/[\s,]+/)
3894
- .filter(Boolean);
3895
- return tokens.includes("all") || tokens.includes("screen-reader");
3896
- }
3897
- function isSafeContentAttribute(name, value) {
3898
- const normalizedName = name.toLowerCase();
3899
- if (normalizedName.startsWith("on"))
3900
- return false;
3901
- if (normalizedName === "style")
3902
- return false;
3903
- if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
3904
- return isSafeUrl(value);
3905
- }
3906
- return (normalizedName === "alt" ||
3907
- normalizedName === "class" ||
3908
- normalizedName === "colspan" ||
3909
- normalizedName === "dir" ||
3910
- normalizedName === "headers" ||
3911
- normalizedName === "height" ||
3912
- normalizedName === "id" ||
3913
- normalizedName === "lang" ||
3914
- normalizedName === "role" ||
3915
- normalizedName === "rowspan" ||
3916
- normalizedName === "scope" ||
3917
- normalizedName === "title" ||
3918
- normalizedName === "type" ||
3919
- normalizedName === "width" ||
3920
- normalizedName === "xml:lang" ||
3921
- mathMlAttributeNames.has(normalizedName) ||
3922
- normalizedName.startsWith("aria-") ||
3923
- normalizedName.startsWith("data-"));
3924
- }
3925
- const mathMlAttributeNames = new Set([
3926
- "accent",
3927
- "accentunder",
3928
- "align",
3929
- "columnalign",
3930
- "display",
3931
- "fence",
3932
- "largeop",
3933
- "lspace",
3934
- "mathbackground",
3935
- "mathcolor",
3936
- "mathsize",
3937
- "mathvariant",
3938
- "movablelimits",
3939
- "rowalign",
3940
- "rspace",
3941
- "separator",
3942
- "stretchy",
3943
- ]);
3944
- function isSafeUrl(value) {
3945
- return (value.startsWith("#") ||
3946
- value.startsWith("/") ||
3947
- value.startsWith("./") ||
3948
- value.startsWith("../") ||
3949
- value.startsWith("http://") ||
3950
- value.startsWith("https://") ||
3951
- value.startsWith("data:image/") ||
3952
- value.startsWith("data:audio/") ||
3953
- value.startsWith("data:video/"));
3954
- }
3955
- function isResolvableAssetUrl(value) {
3956
- return (!value.startsWith("#") &&
3957
- !value.startsWith("data:") &&
3958
- !value.startsWith("blob:") &&
3959
- !value.startsWith("http://") &&
3960
- !value.startsWith("https://"));
3961
- }
3962
- function formatPrintedValue(value, format) {
3963
- if (value === null || value === undefined)
3964
- return "";
3965
- const numericValue = typeof value === "number" ? value : typeof value === "string" ? Number(value) : Number.NaN;
3966
- if (Number.isFinite(numericValue) && format) {
3967
- const fixed = /^%\.(\d+)f$/.exec(format);
3968
- if (fixed)
3969
- return numericValue.toFixed(Number(fixed[1]));
3970
- if (format === "%d" || format === "%i")
3971
- return String(Math.trunc(numericValue));
3972
- }
3973
- if (Array.isArray(value))
3974
- return value.map((item) => String(item)).join(", ");
3975
- if (typeof value === "object")
3976
- return JSON.stringify(value);
3977
- return String(value);
3978
- }
3979
- function contentNodeText(node) {
3980
- if (node.kind === "text")
3981
- return node.text;
3982
- if ("children" in node)
3983
- return node.children.map(contentNodeText).join("");
3984
- return "";
3985
- }
3986
- function readableType(type) {
3987
- return type
3988
- .replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`)
3989
- .replace(/^./, (letter) => letter.toUpperCase());
3990
- }
3991
- function errorView(message) {
3992
- const element = document.createElement("p");
3993
- element.role = "alert";
3994
- element.textContent = message;
3995
- return element;
3996
- }
3997
- function validationMessageElement(responseIdentifier) {
3998
- const element = document.createElement("p");
3999
- element.id = validationMessageId(responseIdentifier);
4000
- element.dataset.validationFor = responseIdentifier;
4001
- element.hidden = true;
4002
- element.role = "alert";
4003
- return element;
4004
- }
4005
- function inlineValidationMessageElement(responseIdentifier) {
4006
- const element = document.createElement("span");
4007
- element.id = validationMessageId(responseIdentifier);
4008
- element.dataset.validationFor = responseIdentifier;
4009
- element.hidden = true;
4010
- element.role = "alert";
4011
- return element;
4012
- }
4013
- function validationMessageId(responseIdentifier) {
4014
- return `qti3-validation-${responseIdentifier}`;
4015
- }
4016
- function cloneDiagnostics(diagnostics) {
4017
- return diagnostics.map((diagnostic) => ({
4018
- ...diagnostic,
4019
- source: diagnostic.source ? { ...diagnostic.source } : undefined,
4020
- }));
4021
- }
4022
- function playerStyleElement() {
4023
- const style = document.createElement("style");
4024
- style.textContent = `
4025
- .qti3-player {
4026
- --qti3-match-accent: #2f6fca;
4027
- --qti3-match-target-bg: #f5f6f7;
4028
- --qti3-match-target-border: #6f7782;
4029
-
4030
- display: grid;
4031
- gap: 1rem;
4032
- max-inline-size: 72rem;
4033
- font: 16px/1.45 system-ui, sans-serif;
4034
- }
4035
-
4036
- @supports (color: light-dark(#000, #fff)) {
4037
- .qti3-player {
4038
- --qti3-match-accent: light-dark(#2f6fca, #8ab4f8);
4039
- --qti3-match-target-bg: light-dark(#f5f6f7, #202124);
4040
- --qti3-match-target-border: light-dark(#6f7782, #9aa0a6);
4041
- }
4042
- }
4043
-
4044
- .qti3-interaction {
4045
- display: grid;
4046
- gap: 0.75rem;
4047
- }
4048
-
4049
- .qti3-item-body {
4050
- display: grid;
4051
- gap: 1rem;
4052
- }
4053
-
4054
- .qti3-item-body > * {
4055
- margin-block: 0;
4056
- }
4057
-
4058
- .qti3-player .qti-hidden {
4059
- display: none !important;
4060
- }
4061
-
4062
- .qti3-player .qti-visually-hidden {
4063
- position: absolute !important;
4064
- overflow: hidden !important;
4065
- clip: rect(1px, 1px, 1px, 1px) !important;
4066
- clip-path: inset(50%) !important;
4067
- inline-size: 1px !important;
4068
- block-size: 1px !important;
4069
- margin: -1px !important;
4070
- padding: 0 !important;
4071
- border: 0 !important;
4072
- white-space: nowrap !important;
4073
- }
4074
-
4075
- .qti3-embedded-interaction {
4076
- display: inline-flex;
4077
- gap: 0.35rem;
4078
- margin-inline: 0.18rem;
4079
- align-items: baseline;
4080
- vertical-align: baseline;
4081
- }
4082
-
4083
- .qti3-inline-text-input {
4084
- inline-size: auto;
4085
- min-inline-size: 8ch;
4086
- max-inline-size: 18ch;
4087
- margin-inline: 0.25rem;
4088
- }
4089
-
4090
- .qti3-printed-variable {
4091
- font-weight: 700;
4092
- }
4093
-
4094
- .qti3-feedback-block {
4095
- padding: 0.75rem;
4096
- border-inline-start: 4px solid Highlight;
4097
- background: Canvas;
4098
- color: CanvasText;
4099
- }
4100
-
4101
- .qti3-response-group {
4102
- min-inline-size: 0;
4103
- }
4104
-
4105
- .qti3-response-group > * + * {
4106
- margin-block-start: 0.75rem;
4107
- }
4108
-
4109
- .qti3-reorder-item,
4110
- .qti3-token-region,
4111
- .qti3-pair-chip,
4112
- .qti3-gap-region,
4113
- .qti3-gap-target {
4114
- display: flex;
4115
- flex-wrap: wrap;
4116
- gap: 0.5rem;
4117
- align-items: center;
4118
- }
4119
-
4120
- .qti3-reorder-list {
4121
- display: grid;
4122
- gap: 0.5rem;
4123
- padding-inline-start: 1.5rem;
4124
- }
4125
-
4126
- .qti3-pair-selector {
4127
- display: grid;
4128
- gap: 0.75rem;
4129
- grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
4130
- align-items: start;
4131
- }
4132
-
4133
- .qti3-match-selector {
4134
- display: grid;
4135
- gap: 1.5rem;
4136
- inline-size: 100%;
4137
- max-inline-size: 72rem;
4138
- box-sizing: border-box;
4139
- }
4140
-
4141
- .qti3-match-source-bank,
4142
- .qti3-match-target-bank {
4143
- align-items: stretch;
4144
- }
4145
-
4146
- .qti3-token.qti3-match-source {
4147
- border-color: var(--qti3-match-accent);
4148
- background: Canvas;
4149
- color: var(--qti3-match-accent);
4150
- }
4151
-
4152
- .qti3-token.qti3-match-target {
4153
- flex: 1 1 9rem;
4154
- min-inline-size: 0;
4155
- max-inline-size: 100%;
4156
- min-block-size: 5rem;
4157
- box-sizing: border-box;
4158
- border-color: var(--qti3-match-target-border);
4159
- background: var(--qti3-match-target-bg);
4160
- color: CanvasText;
4161
- font-weight: 700;
4162
- white-space: normal;
4163
- overflow-wrap: anywhere;
4164
- text-align: center;
4165
- }
4166
-
4167
- @media (forced-colors: active) {
4168
- .qti3-token.qti3-match-source {
4169
- border-color: LinkText;
4170
- color: LinkText;
4171
- }
4172
-
4173
- .qti3-token.qti3-match-target {
4174
- border-color: GrayText;
4175
- background: ButtonFace;
4176
- color: ButtonText;
4177
- }
4178
- }
4179
-
4180
- .qti3-region-label {
4181
- flex-basis: 100%;
4182
- font-size: 0.9rem;
4183
- font-weight: 700;
4184
- }
4185
-
4186
- .qti3-choice-list {
4187
- display: grid;
4188
- gap: 0.5rem;
4189
- grid-template-columns: minmax(0, 42rem);
4190
- }
4191
-
4192
- .qti3-choice-option {
4193
- display: grid;
4194
- grid-template-columns: auto auto minmax(0, 1fr);
4195
- gap: 0.65rem;
4196
- align-items: center;
4197
- justify-content: start;
4198
- inline-size: 100%;
4199
- box-sizing: border-box;
4200
- min-block-size: 2.75rem;
4201
- padding: 0.65rem 0.8rem;
4202
- border: 1px solid CanvasText;
4203
- background: Canvas;
4204
- color: CanvasText;
4205
- cursor: pointer;
4206
- }
4207
-
4208
- .qti3-choice-option input {
4209
- margin: 0;
4210
- inline-size: 1rem;
4211
- block-size: 1rem;
4212
- }
4213
-
4214
- .qti3-choice-label {
4215
- min-inline-size: 1.75rem;
4216
- font-weight: 700;
4217
- }
4218
-
4219
- .qti3-choice-text {
4220
- min-inline-size: 0;
4221
- overflow-wrap: anywhere;
4222
- }
4223
-
4224
- .qti3-choice-option[data-selected="true"] {
4225
- background: Highlight;
4226
- color: HighlightText;
4227
- }
4228
-
4229
- .qti3-hottext-group {
4230
- max-inline-size: 58rem;
4231
- }
4232
-
4233
- .qti3-hottext-passage {
4234
- margin-block: 0;
4235
- font-size: 1.05rem;
4236
- line-height: 1.75;
4237
- }
4238
-
4239
- .qti3-hottext-token {
4240
- display: inline;
4241
- margin-inline: 0.1rem;
4242
- padding: 0.12rem 0.28rem;
4243
- border: 1px solid CanvasText;
4244
- border-radius: 0.2rem;
4245
- background: Canvas;
4246
- color: LinkText;
4247
- font: inherit;
4248
- text-decoration: underline;
4249
- text-decoration-thickness: 0.08em;
4250
- text-underline-offset: 0.16em;
4251
- cursor: pointer;
4252
- }
4253
-
4254
- .qti3-hottext-token[data-selected="true"] {
4255
- background: Highlight;
4256
- color: HighlightText;
4257
- text-decoration-color: HighlightText;
4258
- }
4259
-
4260
- .qti3-reorder-item {
4261
- padding: 0.5rem;
4262
- border: 1px solid CanvasText;
4263
- background: Canvas;
4264
- color: CanvasText;
4265
- }
4266
-
4267
- .qti3-drop-target {
4268
- outline: 3px solid Highlight;
4269
- outline-offset: 2px;
4270
- }
4271
-
4272
- .qti3-token,
4273
- .qti3-icon-button,
4274
- .qti3-player button,
4275
- .qti3-player select,
4276
- .qti3-player input,
4277
- .qti3-player textarea {
4278
- font: inherit;
4279
- }
4280
-
4281
- .qti3-token {
4282
- min-inline-size: 2.5rem;
4283
- padding: 0.35rem 0.65rem;
4284
- border: 1px solid CanvasText;
4285
- background: Canvas;
4286
- color: CanvasText;
4287
- cursor: grab;
4288
- }
4289
-
4290
- .qti3-icon-button {
4291
- display: inline-grid;
4292
- place-items: center;
4293
- inline-size: 2.25rem;
4294
- block-size: 2.25rem;
4295
- padding: 0;
4296
- line-height: 1;
4297
- }
4298
-
4299
- .qti3-remove-button {
4300
- border: 1px solid currentColor;
4301
- background: transparent;
4302
- color: inherit;
4303
- cursor: pointer;
4304
- }
4305
-
4306
- .qti3-remove-button:hover {
4307
- background: color-mix(in srgb, currentColor 14%, transparent);
4308
- }
4309
-
4310
- .qti3-trash-icon {
4311
- inline-size: 1.125rem;
4312
- block-size: 1.125rem;
4313
- }
4314
-
4315
- .qti3-movement-icon {
4316
- inline-size: 1rem;
4317
- block-size: 1rem;
4318
- }
4319
-
4320
- .qti3-trash-icon path,
4321
- .qti3-movement-icon path {
4322
- fill: none;
4323
- stroke: currentColor;
4324
- stroke-width: 2;
4325
- stroke-linecap: round;
4326
- stroke-linejoin: round;
4327
- vector-effect: non-scaling-stroke;
4328
- }
4329
-
4330
- .qti3-token[aria-pressed="true"],
4331
- .qti3-pair-chip {
4332
- background: Highlight;
4333
- color: HighlightText;
4334
- }
4335
-
4336
- .qti3-pair-list {
4337
- display: grid;
4338
- gap: 0.5rem;
4339
- padding-inline-start: 1.5rem;
4340
- }
4341
-
4342
- .qti3-pair-chip {
4343
- width: fit-content;
4344
- padding: 0.35rem 0.5rem;
4345
- }
4346
-
4347
- .qti3-gap-target {
4348
- min-block-size: 2.75rem;
4349
- padding: 0.5rem;
4350
- border: 1px dashed CanvasText;
4351
- }
4352
-
4353
- .qti3-gap-region {
4354
- margin-block-start: 0.5rem;
4355
- }
4356
-
4357
- .qti3-gap-passage {
4358
- display: block;
4359
- max-inline-size: 62rem;
4360
- line-height: 2.3;
4361
- }
4362
-
4363
- .qti3-gap-passage .qti3-gap-target {
4364
- display: inline-flex;
4365
- padding: 0;
4366
- border: 0;
4367
- margin-inline: 0.15rem;
4368
- margin-block: 0.2rem;
4369
- vertical-align: middle;
4370
- }
4371
-
4372
- .qti3-gap-button {
4373
- min-inline-size: 8rem;
4374
- min-block-size: 2.25rem;
4375
- text-align: start;
4376
- }
4377
-
4378
- .qti3-text-response,
4379
- .qti3-slider-response {
4380
- display: grid;
4381
- gap: 0.4rem;
4382
- max-inline-size: 42rem;
4383
- }
4384
-
4385
- .qti3-text-input,
4386
- .qti3-textarea {
4387
- inline-size: 100%;
4388
- box-sizing: border-box;
4389
- padding: 0.55rem 0.65rem;
4390
- border: 1px solid CanvasText;
4391
- background: Canvas;
4392
- color: CanvasText;
4393
- }
4394
-
4395
- .qti3-textarea {
4396
- min-block-size: 8rem;
4397
- resize: vertical;
4398
- }
4399
-
4400
- .qti3-counter,
4401
- .qti3-slider-output {
4402
- margin: 0;
4403
- font-size: 0.9rem;
4404
- }
4405
-
4406
- .qti3-slider-response {
4407
- grid-template-columns: minmax(8rem, 1fr) auto;
4408
- align-items: center;
4409
- }
4410
-
4411
- .qti3-point-controls,
4412
- .qti3-drawing-tools {
4413
- display: flex;
4414
- flex-wrap: wrap;
4415
- gap: 0.5rem;
4416
- margin-block-start: 0.5rem;
4417
- }
4418
-
4419
- .qti3-coordinate-output {
4420
- display: block;
4421
- margin-block-start: 0.4rem;
4422
- font-size: 0.9rem;
4423
- }
4424
-
4425
- .qti3-hotspot-button[data-selected="true"] {
4426
- background: Highlight !important;
4427
- color: HighlightText !important;
4428
- outline: 3px solid Highlight;
4429
- outline-offset: 2px;
4430
- }
4431
-
4432
- .qti3-hotspot-button {
4433
- display: grid;
4434
- place-items: start;
4435
- padding: 0.25rem;
4436
- border: 2px solid CanvasText;
4437
- background: color-mix(in srgb, Canvas 65%, transparent);
4438
- color: CanvasText;
4439
- font-size: 0.8rem;
4440
- font-weight: 700;
4441
- line-height: 1;
4442
- cursor: pointer;
4443
- }
4444
-
4445
- .qti3-hotspot.qti-selections-light .qti3-hotspot-button {
4446
- border-color: white;
4447
- color: white;
4448
- background: rgb(0 0 0 / 0.45);
4449
- }
4450
-
4451
- .qti3-hotspot.qti-selections-dark .qti3-hotspot-button {
4452
- border-color: black;
4453
- color: black;
4454
- background: rgb(255 255 255 / 0.65);
4455
- }
4456
-
4457
- .qti3-hotspot.qti-unselected-hidden
4458
- .qti3-hotspot-button:not([data-selected="true"]):not(:focus):not(:focus-visible) {
4459
- opacity: 0;
4460
- }
4461
-
4462
- @supports not (background: color-mix(in srgb, Canvas 65%, transparent)) {
4463
- .qti3-hotspot-button {
4464
- background: Canvas;
4465
- }
4466
- }
4467
-
4468
- @media (forced-colors: active) {
4469
- .qti3-hotspot.qti-selections-light .qti3-hotspot-button,
4470
- .qti3-hotspot.qti-selections-dark .qti3-hotspot-button {
4471
- border-color: CanvasText;
4472
- color: CanvasText;
4473
- background: Canvas;
4474
- }
4475
- }
4476
-
4477
- .qti3-graphic-associate-surface,
4478
- .qti3-graphic-gap-match-surface,
4479
- .qti3-graphic-order-surface {
4480
- touch-action: manipulation;
4481
- }
4482
-
4483
- .qti3-graphic-associate-lines,
4484
- .qti3-graphic-sequence-lines {
4485
- position: absolute;
4486
- inset: 0;
4487
- inline-size: 100%;
4488
- block-size: 100%;
4489
- pointer-events: none;
4490
- z-index: 1;
4491
- }
4492
-
4493
- .qti3-graphic-associate-lines line,
4494
- .qti3-graphic-sequence-lines line {
4495
- stroke: Highlight;
4496
- stroke-width: 4;
4497
- stroke-linecap: round;
4498
- vector-effect: non-scaling-stroke;
4499
- }
4500
-
4501
- .qti3-graphic-sequence-lines marker path {
4502
- fill: Highlight;
4503
- }
4504
-
4505
- .qti3-graphic-associate-hotspot,
4506
- .qti3-graphic-gap-hotspot,
4507
- .qti3-graphic-order-hotspot {
4508
- z-index: 2;
4509
- }
4510
-
4511
- .qti3-graphic-gap-match-surface {
4512
- margin-block-end: calc(var(--qti3-graphic-gap-label-block-size, 2rem) + 0.75rem);
4513
- }
4514
-
4515
- .qti3-graphic-gap-hotspot {
4516
- display: grid;
4517
- place-items: center;
4518
- padding: 0;
4519
- overflow: visible;
4520
- border-style: dashed;
4521
- background: rgb(255 255 255 / 0.08);
4522
- color: CanvasText;
4523
- }
4524
-
4525
- .qti3-graphic-gap-hotspot[data-selected="true"] {
4526
- border-style: solid;
4527
- background: color-mix(in srgb, Highlight 18%, Canvas);
4528
- }
4529
-
4530
- .qti3-graphic-gap-label {
4531
- position: absolute;
4532
- inset-block-start: calc(100% + 0.2rem);
4533
- inset-inline-start: 50%;
4534
- transform: translateX(-50%);
4535
- box-sizing: border-box;
4536
- inline-size: max-content;
4537
- max-inline-size: min(12rem, calc(100vw - 2rem));
4538
- min-inline-size: 0;
4539
- padding: 0.25rem 0.4rem;
4540
- border: 1px solid CanvasText;
4541
- border-radius: 0.25rem;
4542
- background: Canvas;
4543
- color: CanvasText;
4544
- font-size: 0.75rem;
4545
- font-weight: 700;
4546
- line-height: 1.15;
4547
- overflow-wrap: anywhere;
4548
- pointer-events: none;
4549
- box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
4550
- text-align: center;
4551
- white-space: normal;
4552
- }
4553
-
4554
- @supports not (background: color-mix(in srgb, Highlight 18%, Canvas)) {
4555
- .qti3-graphic-gap-hotspot[data-selected="true"] {
4556
- background: Canvas;
4557
- }
4558
- }
4559
-
4560
- .qti3-graphic-order-hotspot {
4561
- display: grid;
4562
- place-items: center;
4563
- gap: 0.15rem;
4564
- text-align: center;
4565
- }
4566
-
4567
- .qti3-graphic-order-number {
4568
- display: grid;
4569
- place-items: center;
4570
- min-inline-size: 1.45rem;
4571
- min-block-size: 1.45rem;
4572
- border-radius: 999px;
4573
- background: Highlight;
4574
- color: HighlightText;
4575
- font-weight: 700;
4576
- }
4577
-
4578
- .qti3-graphic-order-number:empty {
4579
- display: none;
4580
- }
4581
-
4582
- .qti3-graphic-order-list {
4583
- display: grid;
4584
- gap: 0.5rem;
4585
- padding-inline-start: 1.5rem;
4586
- margin-block: 0.5rem 0;
4587
- }
4588
-
4589
- .qti3-graphic-order-item {
4590
- display: flex;
4591
- flex-wrap: wrap;
4592
- gap: 0.4rem;
4593
- align-items: center;
4594
- }
4595
-
4596
- .qti3-selection-summary {
4597
- margin: 0;
4598
- }
4599
-
4600
- .qti3-token:focus,
4601
- .qti3-hotspot-button:focus,
4602
- .qti3-player button:focus-visible,
4603
- .qti3-player select:focus-visible,
4604
- .qti3-player input:focus-visible,
4605
- .qti3-player textarea:focus-visible {
4606
- outline: 3px solid Highlight;
4607
- outline-offset: 2px;
4608
- }
4609
-
4610
- @media (prefers-reduced-motion: reduce) {
4611
- .qti3-player * {
4612
- scroll-behavior: auto;
4613
- }
4614
- }
4615
- `;
4616
- return style;
4617
- }
4618
- function responseIsEmpty(value) {
4619
- return value === null || value === "" || (Array.isArray(value) && value.length === 0);
4620
- }
4621
- function responseCount(value) {
4622
- return responseIsEmpty(value) ? 0 : Array.isArray(value) ? value.length : 1;
4623
- }
4624
- function maximumAllowedResponses(interaction) {
4625
- if (!interaction)
4626
- return undefined;
4627
- if (interaction.type === "media")
4628
- return maximumMediaPlays(interaction);
4629
- const explicit = interaction.attributes["max-choices"] ?? interaction.attributes["max-associations"];
4630
- if (explicit === undefined)
4631
- return undefined;
4632
- const parsed = Number(explicit);
4633
- if (!Number.isInteger(parsed) || parsed <= 0)
4634
- return undefined;
4635
- return parsed;
4636
- }
4637
- function minimumRequiredResponses(interaction) {
4638
- if (!interaction)
4639
- return 1;
4640
- if (interaction.type === "media")
4641
- return minimumMediaPlays(interaction);
4642
- const explicit = interaction.attributes["min-choices"] ?? interaction.attributes["min-associations"];
4643
- if (explicit === undefined)
4644
- return 1;
4645
- const parsed = Number(explicit);
4646
- return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
4647
- }
4648
- function minimumMediaPlays(interaction) {
4649
- const parsed = Number(interaction.attributes["min-plays"] ?? "0");
4650
- return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
4651
- }
4652
- function maximumMediaPlays(interaction) {
4653
- const parsed = Number(interaction.attributes["max-plays"] ?? "0");
4654
- return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
4655
- }
4656
- function mediaPlayCount(value) {
4657
- const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : 0;
4658
- return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
4659
- }
4660
- function parseBooleanAttribute(value) {
4661
- if (value === "true" || value === "1")
4662
- return true;
4663
- if (value === "false" || value === "0")
4664
- return false;
4665
- return undefined;
4666
- }
4667
- function matchMaxDiagnostics(responseIdentifier, interaction, response) {
4668
- const identifiers = responseChoiceIdentifiers(response);
4669
- if (identifiers.length === 0)
4670
- return [];
4671
- const counts = new Map();
4672
- for (const identifier of identifiers) {
4673
- counts.set(identifier, (counts.get(identifier) ?? 0) + 1);
4674
- }
4675
- const diagnostics = [];
4676
- for (const choice of interaction.choices) {
4677
- const maximum = parseUnlimitedMaximum(choice.attributes["match-max"]);
4678
- if (maximum === undefined)
4679
- continue;
4680
- const count = counts.get(choice.identifier) ?? 0;
4681
- if (count <= maximum)
4682
- continue;
4683
- diagnostics.push({
4684
- code: "response.matchMax",
4685
- severity: "error",
4686
- message: `${choice.text || choice.identifier} may be used at most ${maximum} time${maximum === 1 ? "" : "s"}.`,
4687
- path: responseIdentifier,
4688
- });
4689
- }
4690
- return diagnostics;
4691
- }
4692
- function responseChoiceIdentifiers(response) {
4693
- const values = Array.isArray(response) ? response : response === null ? [] : [response];
4694
- return values.flatMap((value) => String(value).split(/\s+/).filter(Boolean));
4695
- }
4696
- function parseUnlimitedMaximum(value) {
4697
- if (value === undefined)
4698
- return undefined;
4699
- const parsed = Number(value);
4700
- if (!Number.isInteger(parsed) || parsed <= 0)
4701
- return undefined;
4702
- return parsed;
4703
- }
4704
- async function defaultFetchXml(url) {
4705
- if (!globalThis.fetch) {
4706
- throw new Error("No fetch implementation is available. Provide loadUrl(url, { fetchXml }).");
4707
- }
4708
- const response = await globalThis.fetch(url);
4709
- if (!response.ok)
4710
- throw new Error(`Failed to load QTI XML from ${url}: ${response.status}.`);
4711
- return response.text();
4712
- }
1
+ export { QtiAssessmentItemPlayer, defineQtiAssessmentItemPlayer } from "./player-element.js";
4713
2
  //# sourceMappingURL=index.js.map