@longsightgroup/qti3-player 0.1.1 → 0.1.2

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