@longsightgroup/qti3-player 0.1.2 → 0.2.1
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/README.md +29 -0
- package/dist/index.d.ts +35 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +898 -104
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +1018 -107
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
assertQtiAttemptStateV1,
|
|
3
3
|
createItemSession,
|
|
4
|
+
createCatalogSupportResolution,
|
|
5
|
+
createTextToSpeechTraversal,
|
|
4
6
|
parseQtiXml,
|
|
5
7
|
visibleModalFeedback,
|
|
6
8
|
type QtiAssessmentItem,
|
|
@@ -13,7 +15,12 @@ import {
|
|
|
13
15
|
type QtiInteraction,
|
|
14
16
|
type QtiItemSession,
|
|
15
17
|
type QtiObjectAsset,
|
|
18
|
+
type QtiPortableCustomDefinition,
|
|
19
|
+
type QtiPortableCustomStateValue,
|
|
16
20
|
type QtiScoreResult,
|
|
21
|
+
type QtiCatalogSupportResolution,
|
|
22
|
+
type QtiCatalogSupportResolutionOptions,
|
|
23
|
+
type QtiTextToSpeechTraversal,
|
|
17
24
|
type QtiValue,
|
|
18
25
|
} from "@longsightgroup/qti3-core";
|
|
19
26
|
|
|
@@ -37,6 +44,17 @@ export interface QtiPlayerLoadOptions {
|
|
|
37
44
|
resolveAsset?: QtiPlayerResolveAsset | undefined;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
export interface QtiPlayerRemoveMessageParams {
|
|
48
|
+
label: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface QtiPlayerMessages {
|
|
52
|
+
remove: () => string;
|
|
53
|
+
removePair: (params: QtiPlayerRemoveMessageParams) => string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type QtiPlayerMessageOverrides = Partial<QtiPlayerMessages>;
|
|
57
|
+
|
|
40
58
|
export interface QtiReadyEventDetail {
|
|
41
59
|
item: QtiAssessmentItem;
|
|
42
60
|
}
|
|
@@ -50,6 +68,15 @@ export interface QtiResponseChangeEventDetail {
|
|
|
50
68
|
value: QtiValue;
|
|
51
69
|
}
|
|
52
70
|
|
|
71
|
+
export interface QtiPortableCustomMountEventDetail {
|
|
72
|
+
responseIdentifier: string;
|
|
73
|
+
interaction: QtiInteraction;
|
|
74
|
+
definition: QtiPortableCustomDefinition;
|
|
75
|
+
host: HTMLElement;
|
|
76
|
+
value: QtiValue;
|
|
77
|
+
state?: QtiPortableCustomStateValue | undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
53
80
|
export type QtiScoreEventDetail = QtiScoreResult;
|
|
54
81
|
|
|
55
82
|
export interface QtiValidationEventDetail {
|
|
@@ -69,6 +96,7 @@ export interface QtiAssessmentItemPlayerEventDetailMap {
|
|
|
69
96
|
"qti-ready": QtiReadyEventDetail;
|
|
70
97
|
"qti-statechange": QtiStateChangeEventDetail;
|
|
71
98
|
"qti-responsechange": QtiResponseChangeEventDetail;
|
|
99
|
+
"qti-portable-custom-mount": QtiPortableCustomMountEventDetail;
|
|
72
100
|
"qti-score": QtiScoreEventDetail;
|
|
73
101
|
"qti-validation": QtiValidationEventDetail;
|
|
74
102
|
"qti-suspend": QtiSuspendEventDetail;
|
|
@@ -95,15 +123,59 @@ const HTMLElementBase: typeof HTMLElement =
|
|
|
95
123
|
} as unknown as typeof HTMLElement);
|
|
96
124
|
|
|
97
125
|
export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
126
|
+
static get observedAttributes(): string[] {
|
|
127
|
+
return ["language-of-interface", "locale"];
|
|
128
|
+
}
|
|
129
|
+
|
|
98
130
|
private documentModel?: QtiDocument;
|
|
99
131
|
private session?: QtiItemSession;
|
|
100
132
|
private resolveAsset: QtiPlayerResolveAsset | undefined;
|
|
101
133
|
private validationMessages: QtiDiagnostic[] = [];
|
|
134
|
+
private languageOfInterfaceOverride: string | undefined;
|
|
135
|
+
private messageOverrides: QtiPlayerMessageOverrides = {};
|
|
102
136
|
private sessionControl: Required<QtiPlayerSessionControl> = {
|
|
103
137
|
validateResponses: true,
|
|
104
138
|
showFeedback: true,
|
|
105
139
|
};
|
|
106
140
|
|
|
141
|
+
get languageOfInterface(): string {
|
|
142
|
+
return (
|
|
143
|
+
this.languageOfInterfaceOverride ??
|
|
144
|
+
this.getAttribute?.("language-of-interface") ??
|
|
145
|
+
this.getAttribute?.("locale") ??
|
|
146
|
+
defaultPlayerLocale(this)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
set languageOfInterface(value: string | undefined) {
|
|
151
|
+
this.languageOfInterfaceOverride = normalizedLocale(value);
|
|
152
|
+
this.rerenderIfLoaded();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
get locale(): string {
|
|
156
|
+
return this.languageOfInterface;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
set locale(value: string | undefined) {
|
|
160
|
+
this.languageOfInterface = value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get messages(): QtiPlayerMessageOverrides {
|
|
164
|
+
return this.messageOverrides;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
set messages(value: QtiPlayerMessageOverrides | undefined) {
|
|
168
|
+
this.messageOverrides = value ?? {};
|
|
169
|
+
this.rerenderIfLoaded();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void {
|
|
173
|
+
if ((name !== "language-of-interface" && name !== "locale") || oldValue === newValue) {
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
this.rerenderIfLoaded();
|
|
177
|
+
}
|
|
178
|
+
|
|
107
179
|
async loadXml(xml: string, options: QtiPlayerLoadOptions = {}): Promise<void> {
|
|
108
180
|
this.sessionControl = {
|
|
109
181
|
validateResponses: options.sessionControl?.validateResponses ?? true,
|
|
@@ -224,6 +296,18 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
224
296
|
return state;
|
|
225
297
|
}
|
|
226
298
|
|
|
299
|
+
getTextToSpeechTraversal(): QtiTextToSpeechTraversal | undefined {
|
|
300
|
+
if (!this.documentModel) return undefined;
|
|
301
|
+
return createTextToSpeechTraversal(this.documentModel);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
getCatalogSupportResolution(
|
|
305
|
+
options: QtiCatalogSupportResolutionOptions = {},
|
|
306
|
+
): QtiCatalogSupportResolution | undefined {
|
|
307
|
+
if (!this.documentModel) return undefined;
|
|
308
|
+
return createCatalogSupportResolution(this.documentModel, options);
|
|
309
|
+
}
|
|
310
|
+
|
|
227
311
|
private emitStateChange(state = this.serialize()): void {
|
|
228
312
|
if (!state) return;
|
|
229
313
|
this.dispatchPlayerEvent("qti-statechange", { state });
|
|
@@ -236,6 +320,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
236
320
|
this.dispatchEvent(new CustomEvent<QtiAssessmentItemPlayerEventDetailMap[T]>(type, { detail }));
|
|
237
321
|
}
|
|
238
322
|
|
|
323
|
+
private playerMessages(): QtiPlayerMessages {
|
|
324
|
+
return resolvePlayerMessages(this.languageOfInterface, this.messageOverrides);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private rerenderIfLoaded(): void {
|
|
328
|
+
if (!this.documentModel) return;
|
|
329
|
+
this.render();
|
|
330
|
+
this.renderValidationMessages();
|
|
331
|
+
this.updateAttemptAvailability();
|
|
332
|
+
}
|
|
333
|
+
|
|
239
334
|
private render(): void {
|
|
240
335
|
const documentModel = this.documentModel;
|
|
241
336
|
if (!documentModel) return;
|
|
@@ -243,6 +338,10 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
243
338
|
this.applyDefaultStyles();
|
|
244
339
|
const root = document.createElement("article");
|
|
245
340
|
root.className = "qti3-player";
|
|
341
|
+
if (documentModel.item.language) {
|
|
342
|
+
root.lang = documentModel.item.language;
|
|
343
|
+
root.setAttribute("xml:lang", documentModel.item.language);
|
|
344
|
+
}
|
|
246
345
|
root.append(playerStyleElement());
|
|
247
346
|
|
|
248
347
|
if (documentModel.item.prompt && documentModel.item.body.length === 0) {
|
|
@@ -275,6 +374,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
275
374
|
}
|
|
276
375
|
|
|
277
376
|
private renderInteraction(interaction: QtiInteraction): HTMLElement {
|
|
377
|
+
const messages = this.playerMessages();
|
|
278
378
|
const field = document.createElement("section");
|
|
279
379
|
field.className = `qti3-interaction qti3-${interaction.type}`;
|
|
280
380
|
field.classList.add(...qtiSharedClassNames(interaction.attributes.class));
|
|
@@ -283,6 +383,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
283
383
|
field.dataset.responseIdentifier = interaction.responseIdentifier;
|
|
284
384
|
|
|
285
385
|
const heading = document.createElement("h3");
|
|
386
|
+
copySafeAttributes(heading, interaction.promptAttributes ?? {});
|
|
286
387
|
heading.textContent = interactionLabel(interaction);
|
|
287
388
|
field.append(heading);
|
|
288
389
|
if (interaction.responseIdentifier) {
|
|
@@ -301,7 +402,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
301
402
|
const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
|
|
302
403
|
|
|
303
404
|
if (interaction.type === "graphicOrder") {
|
|
304
|
-
field.append(renderGraphicOrderResponse(interaction, update, currentValue));
|
|
405
|
+
field.append(renderGraphicOrderResponse(interaction, update, currentValue, messages));
|
|
305
406
|
return field;
|
|
306
407
|
}
|
|
307
408
|
|
|
@@ -316,17 +417,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
316
417
|
}
|
|
317
418
|
|
|
318
419
|
if (interaction.type === "graphicAssociate") {
|
|
319
|
-
field.append(renderGraphicAssociateResponse(interaction, update, currentValue));
|
|
420
|
+
field.append(renderGraphicAssociateResponse(interaction, update, currentValue, messages));
|
|
320
421
|
return field;
|
|
321
422
|
}
|
|
322
423
|
|
|
323
424
|
if (interaction.type === "match") {
|
|
324
|
-
field.append(renderMatchResponse(interaction, update, currentValue));
|
|
425
|
+
field.append(renderMatchResponse(interaction, update, currentValue, messages));
|
|
325
426
|
return field;
|
|
326
427
|
}
|
|
327
428
|
|
|
328
429
|
if (usesPairResponse(interaction)) {
|
|
329
|
-
field.append(renderPairResponse(interaction, update, currentValue));
|
|
430
|
+
field.append(renderPairResponse(interaction, update, currentValue, messages));
|
|
330
431
|
return field;
|
|
331
432
|
}
|
|
332
433
|
|
|
@@ -371,7 +472,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
371
472
|
}
|
|
372
473
|
|
|
373
474
|
if (interaction.type === "portableCustom") {
|
|
374
|
-
field.append(renderPortableCustomResponse(interaction, update, currentValue));
|
|
475
|
+
field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
|
|
375
476
|
return field;
|
|
376
477
|
}
|
|
377
478
|
|
|
@@ -421,6 +522,99 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
421
522
|
return field;
|
|
422
523
|
}
|
|
423
524
|
|
|
525
|
+
private renderPortableCustomResponse(
|
|
526
|
+
interaction: QtiInteraction,
|
|
527
|
+
update: (value: QtiValue) => void,
|
|
528
|
+
currentValue: QtiValue,
|
|
529
|
+
): HTMLElement {
|
|
530
|
+
const definition =
|
|
531
|
+
interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
|
|
532
|
+
const responseIdentifier =
|
|
533
|
+
interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
|
|
534
|
+
const currentState = responseIdentifier
|
|
535
|
+
? this.currentInteractionState(responseIdentifier)
|
|
536
|
+
: undefined;
|
|
537
|
+
|
|
538
|
+
const group = document.createElement("div");
|
|
539
|
+
group.role = "group";
|
|
540
|
+
group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
|
|
541
|
+
|
|
542
|
+
const host = document.createElement("div");
|
|
543
|
+
host.className = "qti3-portable-custom-host";
|
|
544
|
+
host.tabIndex = 0;
|
|
545
|
+
host.dataset.responseIdentifier = responseIdentifier;
|
|
546
|
+
host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
|
|
547
|
+
host.dataset.module = definition.module ?? "";
|
|
548
|
+
host.dataset.qtiName = interaction.qtiName;
|
|
549
|
+
if (definition.interactionModules?.primaryConfiguration) {
|
|
550
|
+
host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
|
|
551
|
+
}
|
|
552
|
+
if (definition.interactionModules?.secondaryConfiguration) {
|
|
553
|
+
host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
|
|
554
|
+
}
|
|
555
|
+
if (currentState !== undefined) host.dataset.state = JSON.stringify(currentState);
|
|
556
|
+
host.setAttribute("role", "application");
|
|
557
|
+
host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
|
|
558
|
+
host.style.border = "1px solid CanvasText";
|
|
559
|
+
host.style.padding = "0.5rem";
|
|
560
|
+
host.style.marginBlockEnd = "0.5rem";
|
|
561
|
+
|
|
562
|
+
if (definition.interactionMarkup.length > 0) {
|
|
563
|
+
const markup = document.createElement("div");
|
|
564
|
+
markup.className = "qti3-portable-custom-markup";
|
|
565
|
+
markup.append(...this.renderContentNodes(definition.interactionMarkup));
|
|
566
|
+
host.append(markup);
|
|
567
|
+
} else {
|
|
568
|
+
host.textContent = "Portable custom interaction host";
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const fallback = document.createElement("input");
|
|
572
|
+
fallback.type = "hidden";
|
|
573
|
+
fallback.className = "qti3-portable-custom-response";
|
|
574
|
+
fallback.hidden = true;
|
|
575
|
+
fallback.tabIndex = -1;
|
|
576
|
+
fallback.setAttribute("aria-hidden", "true");
|
|
577
|
+
fallback.value = scalarString(currentValue);
|
|
578
|
+
|
|
579
|
+
const handlePortableCustomEvent = (event: Event) => {
|
|
580
|
+
const state = portableCustomEventState(event);
|
|
581
|
+
const value = portableCustomEventValue(event);
|
|
582
|
+
const validity = portableCustomEventValidity(event);
|
|
583
|
+
if (state !== undefined && responseIdentifier && this.session) {
|
|
584
|
+
this.session.setInteractionState(responseIdentifier, state);
|
|
585
|
+
host.dataset.state = JSON.stringify(state);
|
|
586
|
+
}
|
|
587
|
+
if (value !== undefined) {
|
|
588
|
+
fallback.value = String(value ?? "");
|
|
589
|
+
update(value);
|
|
590
|
+
}
|
|
591
|
+
if (validity && responseIdentifier) {
|
|
592
|
+
this.setPortableCustomValidity(responseIdentifier, validity.valid, validity.message);
|
|
593
|
+
this.emitStateChange();
|
|
594
|
+
}
|
|
595
|
+
if (value === undefined && state !== undefined && !validity) this.emitStateChange();
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
host.addEventListener("qti3-portable-custom-response", handlePortableCustomEvent);
|
|
599
|
+
host.addEventListener("qti3-pci-response", handlePortableCustomEvent);
|
|
600
|
+
host.addEventListener("qti3-portable-custom-state", handlePortableCustomEvent);
|
|
601
|
+
host.addEventListener("qti3-portable-custom-validity", handlePortableCustomEvent);
|
|
602
|
+
|
|
603
|
+
queueMicrotask(() => {
|
|
604
|
+
this.dispatchPlayerEvent("qti-portable-custom-mount", {
|
|
605
|
+
responseIdentifier,
|
|
606
|
+
interaction,
|
|
607
|
+
definition,
|
|
608
|
+
host,
|
|
609
|
+
value: currentValue,
|
|
610
|
+
state: currentState,
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
group.append(host, fallback);
|
|
615
|
+
return group;
|
|
616
|
+
}
|
|
617
|
+
|
|
424
618
|
private renderEmbeddedInteraction(interaction: QtiInteraction): HTMLElement {
|
|
425
619
|
if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
|
|
426
620
|
return this.renderInteraction(interaction);
|
|
@@ -480,11 +674,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
480
674
|
}
|
|
481
675
|
if (node.qtiName === "qti-prompt") {
|
|
482
676
|
const prompt = document.createElement("p");
|
|
483
|
-
prompt.
|
|
677
|
+
copySafeAttributes(prompt, node.attributes);
|
|
678
|
+
prompt.classList.add("qti3-item-prompt");
|
|
484
679
|
prompt.append(...this.renderContentNodes(node.children));
|
|
485
680
|
return [prompt];
|
|
486
681
|
}
|
|
487
682
|
|
|
683
|
+
if (unsafeContentElements.has(node.qtiName)) return [];
|
|
488
684
|
const elementName = contentElementName(node.qtiName);
|
|
489
685
|
if (!elementName) return this.renderContentNodes(node.children);
|
|
490
686
|
const element = createContentElement(elementName);
|
|
@@ -652,6 +848,32 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
652
848
|
return this.session?.serialize().responses[identifier] ?? null;
|
|
653
849
|
}
|
|
654
850
|
|
|
851
|
+
private currentInteractionState(identifier: string): QtiPortableCustomStateValue | undefined {
|
|
852
|
+
return this.session?.serialize().interactionStates?.[identifier];
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
private setPortableCustomValidity(
|
|
856
|
+
responseIdentifier: string,
|
|
857
|
+
valid: boolean,
|
|
858
|
+
message: string | undefined,
|
|
859
|
+
): void {
|
|
860
|
+
if (valid) {
|
|
861
|
+
this.clearValidationMessage(responseIdentifier);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const diagnostic: QtiDiagnostic = {
|
|
865
|
+
code: "response.portableCustom.validity",
|
|
866
|
+
severity: "error",
|
|
867
|
+
message: message?.trim() || `${responseIdentifier} is not valid.`,
|
|
868
|
+
path: responseIdentifier,
|
|
869
|
+
};
|
|
870
|
+
this.validationMessages = [
|
|
871
|
+
...this.validationMessages.filter((entry) => entry.path !== responseIdentifier),
|
|
872
|
+
diagnostic,
|
|
873
|
+
];
|
|
874
|
+
this.renderValidationMessages();
|
|
875
|
+
}
|
|
876
|
+
|
|
655
877
|
private applyDefaultStyles(): void {
|
|
656
878
|
this.style.color = "CanvasText";
|
|
657
879
|
this.style.backgroundColor = "Canvas";
|
|
@@ -796,6 +1018,154 @@ declare global {
|
|
|
796
1018
|
}
|
|
797
1019
|
}
|
|
798
1020
|
|
|
1021
|
+
const defaultEnglishPlayerMessages: QtiPlayerMessages = {
|
|
1022
|
+
remove: () => "Remove",
|
|
1023
|
+
removePair: ({ label }) => `Remove ${label}`,
|
|
1024
|
+
};
|
|
1025
|
+
|
|
1026
|
+
const playerMessages = {
|
|
1027
|
+
defaultEnglish: defaultEnglishPlayerMessages,
|
|
1028
|
+
spanish: playerMessageCatalog("Quitar", ({ label }) => `Quitar ${label}`),
|
|
1029
|
+
swedish: playerMessageCatalog("Ta bort", ({ label }) => `Ta bort ${label}`),
|
|
1030
|
+
german: playerMessageCatalog("Entfernen", ({ label }) => `${label} entfernen`),
|
|
1031
|
+
portuguese: playerMessageCatalog("Remover", ({ label }) => `Remover ${label}`),
|
|
1032
|
+
french: playerMessageCatalog("Supprimer", ({ label }) => `Supprimer ${label}`),
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
const builtInPlayerMessageCatalogs: ReadonlyMap<string, QtiPlayerMessages> = new Map([
|
|
1036
|
+
["en", playerMessages.defaultEnglish],
|
|
1037
|
+
["es", playerMessages.spanish],
|
|
1038
|
+
["es-es", playerMessages.spanish],
|
|
1039
|
+
["es-mx", playerMessages.spanish],
|
|
1040
|
+
["sv", playerMessages.swedish],
|
|
1041
|
+
["sv-se", playerMessages.swedish],
|
|
1042
|
+
["de", playerMessages.german],
|
|
1043
|
+
["de-de", playerMessages.german],
|
|
1044
|
+
["pt", playerMessages.portuguese],
|
|
1045
|
+
["pt-br", playerMessages.portuguese],
|
|
1046
|
+
["pt-pt", playerMessages.portuguese],
|
|
1047
|
+
["fr", playerMessages.french],
|
|
1048
|
+
["fr-ca", playerMessages.french],
|
|
1049
|
+
["fr-fr", playerMessages.french],
|
|
1050
|
+
]);
|
|
1051
|
+
|
|
1052
|
+
function playerMessageCatalog(
|
|
1053
|
+
remove: string,
|
|
1054
|
+
removePair: QtiPlayerMessages["removePair"],
|
|
1055
|
+
): QtiPlayerMessages {
|
|
1056
|
+
return {
|
|
1057
|
+
remove: () => remove,
|
|
1058
|
+
removePair,
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function resolvePlayerMessages(
|
|
1063
|
+
locale: string,
|
|
1064
|
+
overrides: QtiPlayerMessageOverrides,
|
|
1065
|
+
): QtiPlayerMessages {
|
|
1066
|
+
const catalog = builtInPlayerMessageCatalog(locale);
|
|
1067
|
+
return {
|
|
1068
|
+
remove: overrides.remove ?? catalog?.remove ?? defaultEnglishPlayerMessages.remove,
|
|
1069
|
+
removePair:
|
|
1070
|
+
overrides.removePair ?? catalog?.removePair ?? defaultEnglishPlayerMessages.removePair,
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function builtInPlayerMessageCatalog(locale: string): QtiPlayerMessages | undefined {
|
|
1075
|
+
for (const candidate of localeFallbacks(locale)) {
|
|
1076
|
+
const catalog = builtInPlayerMessageCatalogs.get(candidate);
|
|
1077
|
+
if (catalog) return catalog;
|
|
1078
|
+
}
|
|
1079
|
+
return undefined;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function localeFallbacks(locale: string): string[] {
|
|
1083
|
+
const normalized = normalizedLocale(locale)?.toLowerCase();
|
|
1084
|
+
if (!normalized) return ["en"];
|
|
1085
|
+
const parts = normalized.split("-");
|
|
1086
|
+
const fallbacks: string[] = [];
|
|
1087
|
+
for (let length = parts.length; length > 0; length -= 1) {
|
|
1088
|
+
fallbacks.push(parts.slice(0, length).join("-"));
|
|
1089
|
+
}
|
|
1090
|
+
return fallbacks.includes("en") ? fallbacks : [...fallbacks, "en"];
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function normalizedLocale(value: string | undefined | null): string | undefined {
|
|
1094
|
+
const trimmed = value?.trim();
|
|
1095
|
+
if (!trimmed) return undefined;
|
|
1096
|
+
try {
|
|
1097
|
+
return Intl.getCanonicalLocales(trimmed)[0] ?? trimmed;
|
|
1098
|
+
} catch {
|
|
1099
|
+
return trimmed;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function defaultPlayerLocale(host?: Element): string {
|
|
1104
|
+
const elementLanguage = normalizedLocale(host?.getAttribute("lang"));
|
|
1105
|
+
if (elementLanguage) return elementLanguage;
|
|
1106
|
+
|
|
1107
|
+
const navigatorLanguages = globalThis.navigator?.languages ?? [];
|
|
1108
|
+
for (const language of navigatorLanguages) {
|
|
1109
|
+
const normalized = normalizedLocale(language);
|
|
1110
|
+
if (normalized) return normalized;
|
|
1111
|
+
}
|
|
1112
|
+
return (
|
|
1113
|
+
normalizedLocale(globalThis.navigator?.language) ??
|
|
1114
|
+
normalizedLocale(host?.closest("[lang]")?.getAttribute("lang")) ??
|
|
1115
|
+
normalizedLocale(host?.ownerDocument?.documentElement.lang) ??
|
|
1116
|
+
normalizedLocale(globalThis.document?.documentElement.lang) ??
|
|
1117
|
+
"en"
|
|
1118
|
+
);
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function removeButton(label: string | null, messages: QtiPlayerMessages): HTMLButtonElement {
|
|
1122
|
+
const safeLabel = label?.trim() || messages.remove();
|
|
1123
|
+
const button = document.createElement("button");
|
|
1124
|
+
button.type = "button";
|
|
1125
|
+
button.className = "qti3-icon-button qti3-remove-button";
|
|
1126
|
+
button.title = messages.remove();
|
|
1127
|
+
button.setAttribute("aria-label", messages.removePair({ label: safeLabel }));
|
|
1128
|
+
button.append(trashIcon());
|
|
1129
|
+
return button;
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
type IconPath = string | { d: string; fill?: string; stroke?: string };
|
|
1133
|
+
|
|
1134
|
+
function inlineIcon(className: string, paths: IconPath[]): SVGSVGElement {
|
|
1135
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
1136
|
+
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
1137
|
+
svg.setAttribute("width", "24");
|
|
1138
|
+
svg.setAttribute("height", "24");
|
|
1139
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
1140
|
+
svg.setAttribute("fill", "none");
|
|
1141
|
+
svg.setAttribute("stroke", "currentColor");
|
|
1142
|
+
svg.setAttribute("stroke-width", "2");
|
|
1143
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
1144
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
1145
|
+
svg.setAttribute("aria-hidden", "true");
|
|
1146
|
+
svg.setAttribute("focusable", "false");
|
|
1147
|
+
svg.setAttribute("class", className);
|
|
1148
|
+
|
|
1149
|
+
for (const entry of paths) {
|
|
1150
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
1151
|
+
if (typeof entry === "string") {
|
|
1152
|
+
path.setAttribute("d", entry);
|
|
1153
|
+
} else {
|
|
1154
|
+
path.setAttribute("d", entry.d);
|
|
1155
|
+
if (entry.stroke) {
|
|
1156
|
+
path.setAttribute("stroke", entry.stroke);
|
|
1157
|
+
path.style.stroke = entry.stroke;
|
|
1158
|
+
}
|
|
1159
|
+
if (entry.fill) {
|
|
1160
|
+
path.setAttribute("fill", entry.fill);
|
|
1161
|
+
path.style.fill = entry.fill;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
svg.append(path);
|
|
1165
|
+
}
|
|
1166
|
+
return svg;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
799
1169
|
function renderChoice(
|
|
800
1170
|
interaction: QtiInteraction,
|
|
801
1171
|
update: (value: QtiValue) => void,
|
|
@@ -866,13 +1236,28 @@ function responseGroup(className?: string): HTMLElement {
|
|
|
866
1236
|
|
|
867
1237
|
type MovementDirection = "up" | "down" | "left" | "right";
|
|
868
1238
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1239
|
+
function trashIcon(): SVGSVGElement {
|
|
1240
|
+
return inlineIcon("qti3-trash-icon", [
|
|
1241
|
+
{ d: "M0 0h24v24H0z", stroke: "none", fill: "none" },
|
|
1242
|
+
"M4 7l16 0",
|
|
1243
|
+
"M10 11l0 6",
|
|
1244
|
+
"M14 11l0 6",
|
|
1245
|
+
"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",
|
|
1246
|
+
"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",
|
|
1247
|
+
]);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const movementIconPaths: Record<MovementDirection, string[]> = {
|
|
1251
|
+
up: ["M12 5l0 14", "M18 11l-6 -6", "M6 11l6 -6"],
|
|
1252
|
+
down: ["M12 5l0 14", "M18 13l-6 6", "M6 13l6 6"],
|
|
1253
|
+
left: ["M5 12l14 0", "M5 12l6 6", "M5 12l6 -6"],
|
|
1254
|
+
right: ["M5 12l14 0", "M13 18l6 -6", "M13 6l6 6"],
|
|
874
1255
|
};
|
|
875
1256
|
|
|
1257
|
+
function movementIcon(direction: MovementDirection): SVGSVGElement {
|
|
1258
|
+
return inlineIcon("qti3-movement-icon", movementIconPaths[direction]);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
876
1261
|
function movementButton(
|
|
877
1262
|
direction: MovementDirection,
|
|
878
1263
|
accessibleName: string,
|
|
@@ -880,10 +1265,10 @@ function movementButton(
|
|
|
880
1265
|
): HTMLButtonElement {
|
|
881
1266
|
const button = document.createElement("button");
|
|
882
1267
|
button.type = "button";
|
|
883
|
-
button.className = "qti3-icon-button";
|
|
1268
|
+
button.className = "qti3-icon-button qti3-move-button";
|
|
884
1269
|
button.dataset.moveDirection = direction;
|
|
885
|
-
button.textContent = movementGlyphs[direction];
|
|
886
1270
|
button.setAttribute("aria-label", accessibleName);
|
|
1271
|
+
button.append(movementIcon(direction));
|
|
887
1272
|
button.addEventListener("click", onClick);
|
|
888
1273
|
return button;
|
|
889
1274
|
}
|
|
@@ -1127,6 +1512,7 @@ function renderPairResponse(
|
|
|
1127
1512
|
interaction: QtiInteraction,
|
|
1128
1513
|
update: (value: QtiValue) => void,
|
|
1129
1514
|
currentValue: QtiValue,
|
|
1515
|
+
messages: QtiPlayerMessages,
|
|
1130
1516
|
): HTMLElement {
|
|
1131
1517
|
const group = responseGroup();
|
|
1132
1518
|
appendGraphicContext(group, interaction);
|
|
@@ -1191,10 +1577,7 @@ function renderPairResponse(
|
|
|
1191
1577
|
item.className = "qti3-pair-chip";
|
|
1192
1578
|
const text = document.createElement("span");
|
|
1193
1579
|
text.textContent = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
|
|
1194
|
-
const remove =
|
|
1195
|
-
remove.type = "button";
|
|
1196
|
-
remove.textContent = "Remove";
|
|
1197
|
-
remove.setAttribute("aria-label", `Remove ${text.textContent}`);
|
|
1580
|
+
const remove = removeButton(text.textContent, messages);
|
|
1198
1581
|
remove.addEventListener("click", () => {
|
|
1199
1582
|
const index = selectedPairs.indexOf(pair);
|
|
1200
1583
|
if (index >= 0) selectedPairs.splice(index, 1);
|
|
@@ -1256,6 +1639,7 @@ function renderMatchResponse(
|
|
|
1256
1639
|
interaction: QtiInteraction,
|
|
1257
1640
|
update: (value: QtiValue) => void,
|
|
1258
1641
|
currentValue: QtiValue,
|
|
1642
|
+
messages: QtiPlayerMessages,
|
|
1259
1643
|
): HTMLElement {
|
|
1260
1644
|
const group = responseGroup();
|
|
1261
1645
|
|
|
@@ -1315,10 +1699,7 @@ function renderMatchResponse(
|
|
|
1315
1699
|
item.className = "qti3-pair-chip";
|
|
1316
1700
|
const text = document.createElement("span");
|
|
1317
1701
|
text.textContent = label;
|
|
1318
|
-
const remove =
|
|
1319
|
-
remove.type = "button";
|
|
1320
|
-
remove.textContent = "Remove";
|
|
1321
|
-
remove.setAttribute("aria-label", `Remove ${label}`);
|
|
1702
|
+
const remove = removeButton(label, messages);
|
|
1322
1703
|
remove.addEventListener("click", () => {
|
|
1323
1704
|
removePair(pair);
|
|
1324
1705
|
syncPressed();
|
|
@@ -1453,6 +1834,7 @@ function renderGraphicOrderResponse(
|
|
|
1453
1834
|
interaction: QtiInteraction,
|
|
1454
1835
|
update: (value: QtiValue) => void,
|
|
1455
1836
|
currentValue: QtiValue,
|
|
1837
|
+
messages: QtiPlayerMessages,
|
|
1456
1838
|
): HTMLElement {
|
|
1457
1839
|
const group = responseGroup();
|
|
1458
1840
|
|
|
@@ -1628,10 +2010,7 @@ function renderGraphicOrderResponse(
|
|
|
1628
2010
|
);
|
|
1629
2011
|
down.disabled = index === currentChoices.length - 1;
|
|
1630
2012
|
|
|
1631
|
-
const remove =
|
|
1632
|
-
remove.type = "button";
|
|
1633
|
-
remove.textContent = "Remove";
|
|
1634
|
-
remove.setAttribute("aria-label", `Remove ${choiceLabel}`);
|
|
2013
|
+
const remove = removeButton(choiceLabel, messages);
|
|
1635
2014
|
remove.addEventListener("click", () => removeHotspot(choice.identifier));
|
|
1636
2015
|
|
|
1637
2016
|
item.append(label, up, down, remove);
|
|
@@ -1682,6 +2061,7 @@ function renderGraphicAssociateResponse(
|
|
|
1682
2061
|
interaction: QtiInteraction,
|
|
1683
2062
|
update: (value: QtiValue) => void,
|
|
1684
2063
|
currentValue: QtiValue,
|
|
2064
|
+
messages: QtiPlayerMessages,
|
|
1685
2065
|
): HTMLElement {
|
|
1686
2066
|
const group = responseGroup();
|
|
1687
2067
|
|
|
@@ -1692,6 +2072,12 @@ function renderGraphicAssociateResponse(
|
|
|
1692
2072
|
const maximumAssociations =
|
|
1693
2073
|
interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
|
|
1694
2074
|
let selectedHotspot: QtiChoice | undefined;
|
|
2075
|
+
let draggedHotspot: QtiChoice | undefined;
|
|
2076
|
+
let dragPointerId: number | undefined;
|
|
2077
|
+
let dragStart: { x: number; y: number } | undefined;
|
|
2078
|
+
let dragStarted = false;
|
|
2079
|
+
let suppressNextClick = false;
|
|
2080
|
+
let previewLine: SVGLineElement | undefined;
|
|
1695
2081
|
|
|
1696
2082
|
const surface = document.createElement("div");
|
|
1697
2083
|
surface.className = "qti3-graphic-associate-surface";
|
|
@@ -1785,6 +2171,52 @@ function renderGraphicAssociateResponse(
|
|
|
1785
2171
|
renderState();
|
|
1786
2172
|
commit();
|
|
1787
2173
|
};
|
|
2174
|
+
const authoredPointFromPointer = (event: PointerEvent) => {
|
|
2175
|
+
const rect = surface.getBoundingClientRect();
|
|
2176
|
+
return {
|
|
2177
|
+
x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
|
|
2178
|
+
y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
|
|
2179
|
+
};
|
|
2180
|
+
};
|
|
2181
|
+
const removePreviewLine = () => {
|
|
2182
|
+
previewLine?.remove();
|
|
2183
|
+
previewLine = undefined;
|
|
2184
|
+
};
|
|
2185
|
+
const suppressFollowingClick = () => {
|
|
2186
|
+
suppressNextClick = true;
|
|
2187
|
+
setTimeout(() => {
|
|
2188
|
+
suppressNextClick = false;
|
|
2189
|
+
}, 0);
|
|
2190
|
+
};
|
|
2191
|
+
const updatePreviewLine = (source: QtiChoice, event: PointerEvent) => {
|
|
2192
|
+
const start = hotspotCenter(source, width, height);
|
|
2193
|
+
const end = authoredPointFromPointer(event);
|
|
2194
|
+
if (!previewLine) {
|
|
2195
|
+
previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
2196
|
+
previewLine.dataset.preview = "true";
|
|
2197
|
+
connections.append(previewLine);
|
|
2198
|
+
}
|
|
2199
|
+
previewLine.setAttribute("x1", String(start.x));
|
|
2200
|
+
previewLine.setAttribute("y1", String(start.y));
|
|
2201
|
+
previewLine.setAttribute("x2", String(end.x));
|
|
2202
|
+
previewLine.setAttribute("y2", String(end.y));
|
|
2203
|
+
};
|
|
2204
|
+
const hotspotFromPointer = (event: PointerEvent) => {
|
|
2205
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
2206
|
+
const button = element?.closest<HTMLButtonElement>(".qti3-graphic-associate-hotspot");
|
|
2207
|
+
const identifier = button?.dataset.choiceIdentifier;
|
|
2208
|
+
return choices.find((choice) => choice.identifier === identifier);
|
|
2209
|
+
};
|
|
2210
|
+
const finishDrag = (event: PointerEvent, source: QtiChoice) => {
|
|
2211
|
+
const target = hotspotFromPointer(event);
|
|
2212
|
+
removePreviewLine();
|
|
2213
|
+
if (target) {
|
|
2214
|
+
addPair(source, target);
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
selectedHotspot = undefined;
|
|
2218
|
+
renderState();
|
|
2219
|
+
};
|
|
1788
2220
|
const chooseHotspot = (choice: QtiChoice) => {
|
|
1789
2221
|
if (!selectedHotspot) {
|
|
1790
2222
|
selectedHotspot = choice;
|
|
@@ -1840,10 +2272,7 @@ function renderGraphicAssociateResponse(
|
|
|
1840
2272
|
item.className = "qti3-pair-chip";
|
|
1841
2273
|
const text = document.createElement("span");
|
|
1842
2274
|
text.textContent = pairLabel;
|
|
1843
|
-
const remove =
|
|
1844
|
-
remove.type = "button";
|
|
1845
|
-
remove.textContent = "Remove";
|
|
1846
|
-
remove.setAttribute("aria-label", `Remove ${pairLabel}`);
|
|
2275
|
+
const remove = removeButton(pairLabel, messages);
|
|
1847
2276
|
remove.addEventListener("click", () => removePair(pair));
|
|
1848
2277
|
item.append(text, remove);
|
|
1849
2278
|
return item;
|
|
@@ -1861,8 +2290,60 @@ function renderGraphicAssociateResponse(
|
|
|
1861
2290
|
button.setAttribute("aria-pressed", "false");
|
|
1862
2291
|
button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
|
|
1863
2292
|
button.style.position = "absolute";
|
|
2293
|
+
button.style.touchAction = "none";
|
|
1864
2294
|
placeHotspotButton(button, choice, width, height);
|
|
1865
|
-
button.addEventListener("click", () =>
|
|
2295
|
+
button.addEventListener("click", (event) => {
|
|
2296
|
+
if (suppressNextClick) {
|
|
2297
|
+
suppressNextClick = false;
|
|
2298
|
+
event.preventDefault();
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
chooseHotspot(choice);
|
|
2302
|
+
});
|
|
2303
|
+
button.addEventListener("pointerdown", (event) => {
|
|
2304
|
+
if (event.button !== 0) return;
|
|
2305
|
+
draggedHotspot = choice;
|
|
2306
|
+
dragPointerId = event.pointerId;
|
|
2307
|
+
dragStart = { x: event.clientX, y: event.clientY };
|
|
2308
|
+
dragStarted = false;
|
|
2309
|
+
button.setPointerCapture(event.pointerId);
|
|
2310
|
+
});
|
|
2311
|
+
button.addEventListener("pointermove", (event) => {
|
|
2312
|
+
if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart) return;
|
|
2313
|
+
const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
|
2314
|
+
if (!dragStarted && moved < 4) return;
|
|
2315
|
+
if (!dragStarted) {
|
|
2316
|
+
dragStarted = true;
|
|
2317
|
+
suppressFollowingClick();
|
|
2318
|
+
selectedHotspot = draggedHotspot;
|
|
2319
|
+
renderState();
|
|
2320
|
+
}
|
|
2321
|
+
updatePreviewLine(draggedHotspot, event);
|
|
2322
|
+
event.preventDefault();
|
|
2323
|
+
});
|
|
2324
|
+
button.addEventListener("pointerup", (event) => {
|
|
2325
|
+
if (dragPointerId !== event.pointerId || !draggedHotspot) return;
|
|
2326
|
+
const source = draggedHotspot;
|
|
2327
|
+
draggedHotspot = undefined;
|
|
2328
|
+
dragPointerId = undefined;
|
|
2329
|
+
dragStart = undefined;
|
|
2330
|
+
button.releasePointerCapture(event.pointerId);
|
|
2331
|
+
if (!dragStarted) return;
|
|
2332
|
+
dragStarted = false;
|
|
2333
|
+
suppressFollowingClick();
|
|
2334
|
+
finishDrag(event, source);
|
|
2335
|
+
event.preventDefault();
|
|
2336
|
+
});
|
|
2337
|
+
button.addEventListener("pointercancel", (event) => {
|
|
2338
|
+
if (dragPointerId !== event.pointerId) return;
|
|
2339
|
+
draggedHotspot = undefined;
|
|
2340
|
+
dragPointerId = undefined;
|
|
2341
|
+
dragStart = undefined;
|
|
2342
|
+
dragStarted = false;
|
|
2343
|
+
removePreviewLine();
|
|
2344
|
+
selectedHotspot = undefined;
|
|
2345
|
+
renderState();
|
|
2346
|
+
});
|
|
1866
2347
|
button.addEventListener("keydown", (event) => {
|
|
1867
2348
|
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
1868
2349
|
event.preventDefault();
|
|
@@ -1888,6 +2369,14 @@ function renderGapMatchResponse(
|
|
|
1888
2369
|
update: (value: QtiValue) => void,
|
|
1889
2370
|
currentValue: QtiValue,
|
|
1890
2371
|
): HTMLElement {
|
|
2372
|
+
if (
|
|
2373
|
+
interaction.type === "graphicGapMatch" &&
|
|
2374
|
+
interaction.object &&
|
|
2375
|
+
interaction.choices.some((choice) => choice.role === "hotspot")
|
|
2376
|
+
) {
|
|
2377
|
+
return renderGraphicGapMatchResponse(interaction, update, currentValue);
|
|
2378
|
+
}
|
|
2379
|
+
|
|
1891
2380
|
const group = responseGroup();
|
|
1892
2381
|
appendGraphicContext(group, interaction);
|
|
1893
2382
|
|
|
@@ -2012,6 +2501,175 @@ function renderGapMatchResponse(
|
|
|
2012
2501
|
return group;
|
|
2013
2502
|
}
|
|
2014
2503
|
|
|
2504
|
+
function renderGraphicGapMatchResponse(
|
|
2505
|
+
interaction: QtiInteraction,
|
|
2506
|
+
update: (value: QtiValue) => void,
|
|
2507
|
+
currentValue: QtiValue,
|
|
2508
|
+
): HTMLElement {
|
|
2509
|
+
const group = responseGroup();
|
|
2510
|
+
const width = objectWidth(interaction);
|
|
2511
|
+
const height = objectHeight(interaction);
|
|
2512
|
+
const sources = sourceChoices(interaction);
|
|
2513
|
+
const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
|
|
2514
|
+
const assignments = new Map<string, QtiChoice>();
|
|
2515
|
+
let selectedSource: QtiChoice | undefined;
|
|
2516
|
+
let draggedSource: string | undefined;
|
|
2517
|
+
|
|
2518
|
+
for (const pair of valueToStrings(currentValue)) {
|
|
2519
|
+
const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
|
|
2520
|
+
const source = sources.find((choice) => choice.identifier === sourceIdentifier);
|
|
2521
|
+
if (source && gapIdentifier) assignments.set(gapIdentifier, source);
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
const surface = document.createElement("div");
|
|
2525
|
+
surface.className = "qti3-graphic-context qti3-graphic-gap-match-surface";
|
|
2526
|
+
surface.role = "group";
|
|
2527
|
+
surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
|
|
2528
|
+
surface.style.position = "relative";
|
|
2529
|
+
surface.style.inlineSize = `${width}px`;
|
|
2530
|
+
surface.style.aspectRatio = `${width} / ${height}`;
|
|
2531
|
+
surface.style.maxInlineSize = "100%";
|
|
2532
|
+
surface.style.border = "1px solid CanvasText";
|
|
2533
|
+
surface.style.background = "Canvas";
|
|
2534
|
+
surface.style.overflow = "visible";
|
|
2535
|
+
surface.style.setProperty(
|
|
2536
|
+
"--qti3-graphic-gap-label-block-size",
|
|
2537
|
+
`${graphicGapLabelBlockSize(sources)}rem`,
|
|
2538
|
+
);
|
|
2539
|
+
|
|
2540
|
+
if (interaction.object?.data && objectIsImage(interaction.object)) {
|
|
2541
|
+
const image = document.createElement("img");
|
|
2542
|
+
image.src = interaction.object.data;
|
|
2543
|
+
image.alt = interaction.object.text || `${readableType(interaction.type)} image`;
|
|
2544
|
+
image.style.position = "absolute";
|
|
2545
|
+
image.style.inset = "0";
|
|
2546
|
+
image.style.inlineSize = "100%";
|
|
2547
|
+
image.style.blockSize = "100%";
|
|
2548
|
+
image.style.objectFit = "contain";
|
|
2549
|
+
image.style.pointerEvents = "none";
|
|
2550
|
+
surface.append(image);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
|
|
2554
|
+
sourceRegion.classList.add("qti3-graphic-gap-source-region");
|
|
2555
|
+
const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
|
|
2556
|
+
if (choicesWidth !== undefined) sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
|
|
2557
|
+
|
|
2558
|
+
const summary = document.createElement("p");
|
|
2559
|
+
summary.className = "qti3-selection-summary";
|
|
2560
|
+
summary.setAttribute("aria-live", "polite");
|
|
2561
|
+
|
|
2562
|
+
const commit = () => {
|
|
2563
|
+
update(
|
|
2564
|
+
[...assignments.entries()].map(
|
|
2565
|
+
([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`,
|
|
2566
|
+
),
|
|
2567
|
+
);
|
|
2568
|
+
};
|
|
2569
|
+
const syncSources = () => {
|
|
2570
|
+
for (const button of sourceRegion.querySelectorAll<HTMLButtonElement>("button")) {
|
|
2571
|
+
button.setAttribute(
|
|
2572
|
+
"aria-pressed",
|
|
2573
|
+
button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false",
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
};
|
|
2577
|
+
const clearSourceIfSingleUse = (source: QtiChoice, keepGapIdentifier: string) => {
|
|
2578
|
+
if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1) return;
|
|
2579
|
+
for (const [gapIdentifier, assigned] of assignments.entries()) {
|
|
2580
|
+
if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
|
|
2581
|
+
assignments.delete(gapIdentifier);
|
|
2582
|
+
}
|
|
2583
|
+
}
|
|
2584
|
+
};
|
|
2585
|
+
const assign = (gap: QtiChoice, sourceIdentifier: string | undefined) => {
|
|
2586
|
+
const source = sources.find((choice) => choice.identifier === sourceIdentifier);
|
|
2587
|
+
if (!source) return;
|
|
2588
|
+
clearSourceIfSingleUse(source, gap.identifier);
|
|
2589
|
+
assignments.set(gap.identifier, source);
|
|
2590
|
+
selectedSource = undefined;
|
|
2591
|
+
syncSources();
|
|
2592
|
+
renderTargets();
|
|
2593
|
+
commit();
|
|
2594
|
+
};
|
|
2595
|
+
const targetLabel = (gap: QtiChoice, index: number) =>
|
|
2596
|
+
gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
|
|
2597
|
+
const renderTargetButton = (gap: QtiChoice, index: number): HTMLButtonElement => {
|
|
2598
|
+
const assigned = assignments.get(gap.identifier);
|
|
2599
|
+
const label = targetLabel(gap, index);
|
|
2600
|
+
const button = document.createElement("button");
|
|
2601
|
+
button.type = "button";
|
|
2602
|
+
button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
|
|
2603
|
+
button.dataset.gapIdentifier = gap.identifier;
|
|
2604
|
+
button.dataset.selected = assigned ? "true" : "false";
|
|
2605
|
+
button.setAttribute(
|
|
2606
|
+
"aria-label",
|
|
2607
|
+
assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`,
|
|
2608
|
+
);
|
|
2609
|
+
button.addEventListener("dragover", (event) => {
|
|
2610
|
+
event.preventDefault();
|
|
2611
|
+
button.classList.add("qti3-drop-target");
|
|
2612
|
+
});
|
|
2613
|
+
button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
|
|
2614
|
+
button.addEventListener("drop", (event) => {
|
|
2615
|
+
event.preventDefault();
|
|
2616
|
+
button.classList.remove("qti3-drop-target");
|
|
2617
|
+
assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
|
|
2618
|
+
});
|
|
2619
|
+
button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
|
|
2620
|
+
button.addEventListener("keydown", (event) => {
|
|
2621
|
+
if (event.key !== "Delete" && event.key !== "Backspace") return;
|
|
2622
|
+
if (!assignments.has(gap.identifier)) return;
|
|
2623
|
+
event.preventDefault();
|
|
2624
|
+
assignments.delete(gap.identifier);
|
|
2625
|
+
renderTargets();
|
|
2626
|
+
commit();
|
|
2627
|
+
});
|
|
2628
|
+
button.style.position = "absolute";
|
|
2629
|
+
placeHotspotButton(button, gap, width, height);
|
|
2630
|
+
if (assigned) {
|
|
2631
|
+
const assignedLabel = document.createElement("span");
|
|
2632
|
+
assignedLabel.className = "qti3-graphic-gap-label";
|
|
2633
|
+
assignedLabel.textContent = assigned.text;
|
|
2634
|
+
button.append(assignedLabel);
|
|
2635
|
+
}
|
|
2636
|
+
return button;
|
|
2637
|
+
};
|
|
2638
|
+
const renderTargets = () => {
|
|
2639
|
+
surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
|
|
2640
|
+
for (const [index, gap] of gaps.entries()) {
|
|
2641
|
+
surface.append(renderTargetButton(gap, index));
|
|
2642
|
+
}
|
|
2643
|
+
summary.textContent =
|
|
2644
|
+
assignments.size > 0
|
|
2645
|
+
? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
|
|
2646
|
+
: "No labels placed.";
|
|
2647
|
+
};
|
|
2648
|
+
|
|
2649
|
+
for (const source of sources) {
|
|
2650
|
+
const button = tokenButton(source);
|
|
2651
|
+
button.draggable = true;
|
|
2652
|
+
button.addEventListener("dragstart", (event) => {
|
|
2653
|
+
draggedSource = source.identifier;
|
|
2654
|
+
event.dataTransfer?.setData("text/plain", source.identifier);
|
|
2655
|
+
event.dataTransfer?.setDragImage(button, 8, 8);
|
|
2656
|
+
});
|
|
2657
|
+
button.addEventListener("dragend", () => {
|
|
2658
|
+
draggedSource = undefined;
|
|
2659
|
+
syncSources();
|
|
2660
|
+
});
|
|
2661
|
+
button.addEventListener("click", () => {
|
|
2662
|
+
selectedSource = source;
|
|
2663
|
+
syncSources();
|
|
2664
|
+
});
|
|
2665
|
+
sourceRegion.append(button);
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
renderTargets();
|
|
2669
|
+
group.append(surface, sourceRegion, summary);
|
|
2670
|
+
return group;
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2015
2673
|
function renderSelect(
|
|
2016
2674
|
interaction: QtiInteraction,
|
|
2017
2675
|
update: (value: QtiValue) => void,
|
|
@@ -2376,10 +3034,9 @@ function renderPositionObjectResponse(
|
|
|
2376
3034
|
const height = objectAssetHeight(stageObject, 300);
|
|
2377
3035
|
const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
|
|
2378
3036
|
const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
};
|
|
3037
|
+
const parsedPoint = parsePointValue(currentValue);
|
|
3038
|
+
let point = parsedPoint ?? { x: 0, y: 0 };
|
|
3039
|
+
let isPlaced = Boolean(parsedPoint);
|
|
2383
3040
|
|
|
2384
3041
|
const stage = document.createElement("div");
|
|
2385
3042
|
stage.className = "qti3-position-object-stage";
|
|
@@ -2393,8 +3050,9 @@ function renderPositionObjectResponse(
|
|
|
2393
3050
|
stage.style.border = "1px solid CanvasText";
|
|
2394
3051
|
stage.style.background = "Canvas";
|
|
2395
3052
|
stage.style.color = "CanvasText";
|
|
2396
|
-
stage.style.overflow = "
|
|
3053
|
+
stage.style.overflow = "visible";
|
|
2397
3054
|
stage.style.touchAction = "none";
|
|
3055
|
+
stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
|
|
2398
3056
|
|
|
2399
3057
|
if (stageObject?.data && objectIsImage(stageObject)) {
|
|
2400
3058
|
const image = document.createElement("img");
|
|
@@ -2446,10 +3104,24 @@ function renderPositionObjectResponse(
|
|
|
2446
3104
|
point.y = Math.max(0, Math.min(height, point.y));
|
|
2447
3105
|
};
|
|
2448
3106
|
const commit = () => {
|
|
3107
|
+
if (!isPlaced) return;
|
|
2449
3108
|
update(pointToString(point));
|
|
2450
3109
|
};
|
|
2451
3110
|
const syncMarker = () => {
|
|
3111
|
+
if (!isPlaced) {
|
|
3112
|
+
marker.dataset.placed = "false";
|
|
3113
|
+
marker.style.insetInlineStart = `${Math.round(movableWidth / 2)}px`;
|
|
3114
|
+
marker.style.insetBlockStart = `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`;
|
|
3115
|
+
coordinate.value = "";
|
|
3116
|
+
coordinate.textContent = "Object not placed";
|
|
3117
|
+
stage.setAttribute(
|
|
3118
|
+
"aria-label",
|
|
3119
|
+
`${readableType(interaction.type)} placement stage, object not placed`,
|
|
3120
|
+
);
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
2452
3123
|
clamp();
|
|
3124
|
+
marker.dataset.placed = "true";
|
|
2453
3125
|
marker.style.insetInlineStart = `${percent(point.x, width)}%`;
|
|
2454
3126
|
marker.style.insetBlockStart = `${percent(point.y, height)}%`;
|
|
2455
3127
|
coordinate.value = pointToString(point);
|
|
@@ -2465,9 +3137,16 @@ function renderPositionObjectResponse(
|
|
|
2465
3137
|
x: Math.round(((event.clientX - rect.left) / rect.width) * width),
|
|
2466
3138
|
y: Math.round(((event.clientY - rect.top) / rect.height) * height),
|
|
2467
3139
|
};
|
|
3140
|
+
isPlaced = true;
|
|
2468
3141
|
clamp();
|
|
2469
3142
|
};
|
|
3143
|
+
const ensureKeyboardPoint = () => {
|
|
3144
|
+
if (isPlaced) return;
|
|
3145
|
+
point = { x: 0, y: 0 };
|
|
3146
|
+
isPlaced = true;
|
|
3147
|
+
};
|
|
2470
3148
|
const moveBy = (dx: number, dy: number, emit = true) => {
|
|
3149
|
+
ensureKeyboardPoint();
|
|
2471
3150
|
point.x += dx;
|
|
2472
3151
|
point.y += dy;
|
|
2473
3152
|
syncMarker();
|
|
@@ -2479,22 +3158,30 @@ function renderPositionObjectResponse(
|
|
|
2479
3158
|
else if (event.key === "ArrowRight") moveBy(step, 0, false);
|
|
2480
3159
|
else if (event.key === "ArrowUp") moveBy(0, -step, false);
|
|
2481
3160
|
else if (event.key === "ArrowDown") moveBy(0, step, false);
|
|
2482
|
-
else if (event.key === "Enter" || event.key === " ")
|
|
2483
|
-
|
|
3161
|
+
else if (event.key === "Enter" || event.key === " ") {
|
|
3162
|
+
ensureKeyboardPoint();
|
|
3163
|
+
syncMarker();
|
|
3164
|
+
commit();
|
|
3165
|
+
} else return;
|
|
2484
3166
|
event.preventDefault();
|
|
2485
3167
|
};
|
|
2486
3168
|
|
|
2487
3169
|
let dragging = false;
|
|
3170
|
+
let dragMoved = false;
|
|
2488
3171
|
marker.addEventListener("pointerdown", (event) => {
|
|
2489
3172
|
dragging = true;
|
|
3173
|
+
dragMoved = false;
|
|
2490
3174
|
marker.setPointerCapture(event.pointerId);
|
|
2491
3175
|
marker.style.cursor = "grabbing";
|
|
2492
|
-
|
|
2493
|
-
|
|
3176
|
+
if (isPlaced) {
|
|
3177
|
+
pointFromPointer(event);
|
|
3178
|
+
syncMarker();
|
|
3179
|
+
}
|
|
2494
3180
|
event.preventDefault();
|
|
2495
3181
|
});
|
|
2496
3182
|
marker.addEventListener("pointermove", (event) => {
|
|
2497
3183
|
if (!dragging) return;
|
|
3184
|
+
dragMoved = true;
|
|
2498
3185
|
pointFromPointer(event);
|
|
2499
3186
|
syncMarker();
|
|
2500
3187
|
});
|
|
@@ -2503,9 +3190,11 @@ function renderPositionObjectResponse(
|
|
|
2503
3190
|
dragging = false;
|
|
2504
3191
|
marker.releasePointerCapture(event.pointerId);
|
|
2505
3192
|
marker.style.cursor = "grab";
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
3193
|
+
if (dragMoved || isPlaced) {
|
|
3194
|
+
pointFromPointer(event);
|
|
3195
|
+
syncMarker();
|
|
3196
|
+
commit();
|
|
3197
|
+
}
|
|
2509
3198
|
});
|
|
2510
3199
|
marker.addEventListener("pointercancel", () => {
|
|
2511
3200
|
dragging = false;
|
|
@@ -2685,52 +3374,6 @@ function renderDrawingResponse(
|
|
|
2685
3374
|
return group;
|
|
2686
3375
|
}
|
|
2687
3376
|
|
|
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
3377
|
function renderHotspotResponse(
|
|
2735
3378
|
interaction: QtiInteraction,
|
|
2736
3379
|
update: (value: QtiValue) => void,
|
|
@@ -2884,6 +3527,7 @@ function configureMediaElement(
|
|
|
2884
3527
|
const sourceElement = document.createElement("source");
|
|
2885
3528
|
sourceElement.src = source.src;
|
|
2886
3529
|
if (source.type) sourceElement.type = source.type;
|
|
3530
|
+
copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
|
|
2887
3531
|
media.append(sourceElement);
|
|
2888
3532
|
}
|
|
2889
3533
|
for (const track of object.tracks) {
|
|
@@ -2894,6 +3538,7 @@ function configureMediaElement(
|
|
|
2894
3538
|
if (track.srclang) trackElement.srclang = track.srclang;
|
|
2895
3539
|
if (track.label) trackElement.label = track.label;
|
|
2896
3540
|
if (track.default) trackElement.default = true;
|
|
3541
|
+
copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
|
|
2897
3542
|
media.append(trackElement);
|
|
2898
3543
|
}
|
|
2899
3544
|
|
|
@@ -2907,6 +3552,30 @@ function copyMediaDataAttributes(element: HTMLElement, attributes: Record<string
|
|
|
2907
3552
|
}
|
|
2908
3553
|
}
|
|
2909
3554
|
|
|
3555
|
+
const sourceAttributeNames = new Set(["src", "srcset", "type"]);
|
|
3556
|
+
const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
|
|
3557
|
+
|
|
3558
|
+
function copySafeMediaChildAttributes(
|
|
3559
|
+
element: HTMLElement,
|
|
3560
|
+
attributes: Record<string, string>,
|
|
3561
|
+
controlledNames: Set<string>,
|
|
3562
|
+
): void {
|
|
3563
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
3564
|
+
const normalizedName = name.toLowerCase();
|
|
3565
|
+
if (controlledNames.has(normalizedName)) continue;
|
|
3566
|
+
if (
|
|
3567
|
+
normalizedName === "class" ||
|
|
3568
|
+
normalizedName === "id" ||
|
|
3569
|
+
normalizedName === "title" ||
|
|
3570
|
+
normalizedName === "media" ||
|
|
3571
|
+
normalizedName === "sizes" ||
|
|
3572
|
+
normalizedName.startsWith("data-")
|
|
3573
|
+
) {
|
|
3574
|
+
element.setAttribute(name, value);
|
|
3575
|
+
}
|
|
3576
|
+
}
|
|
3577
|
+
}
|
|
3578
|
+
|
|
2910
3579
|
function mediaElementType(object: QtiObjectAsset): "audio" | "video" | undefined {
|
|
2911
3580
|
const types = [object.type, ...object.sources.map((source) => source.type)].filter(
|
|
2912
3581
|
(value): value is string => Boolean(value),
|
|
@@ -3036,6 +3705,10 @@ function choiceText(choices: QtiChoice[], identifier: string | undefined): strin
|
|
|
3036
3705
|
|
|
3037
3706
|
function sourceChoices(interaction: QtiInteraction): QtiChoice[] {
|
|
3038
3707
|
const choices = choicesOrFallback(interaction);
|
|
3708
|
+
if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
|
|
3709
|
+
const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
|
|
3710
|
+
return gapChoices.length > 0 ? gapChoices : choices;
|
|
3711
|
+
}
|
|
3039
3712
|
const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
|
|
3040
3713
|
const sources = choices.filter((choice) => sourceRoles.has(choice.role));
|
|
3041
3714
|
return sources.length > 0 ? sources : choices;
|
|
@@ -3218,6 +3891,20 @@ function dimension(value: string | undefined, fallback: number): number {
|
|
|
3218
3891
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
3219
3892
|
}
|
|
3220
3893
|
|
|
3894
|
+
function positivePixelValue(value: string | undefined): number | undefined {
|
|
3895
|
+
const parsed = Number(value);
|
|
3896
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
function graphicGapLabelBlockSize(sources: QtiChoice[]): number {
|
|
3900
|
+
const maxLength = Math.max(
|
|
3901
|
+
0,
|
|
3902
|
+
...sources.map((source) => (source.text || source.identifier).trim().length),
|
|
3903
|
+
);
|
|
3904
|
+
const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
|
|
3905
|
+
return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
|
|
3906
|
+
}
|
|
3907
|
+
|
|
3221
3908
|
function placeHotspotButton(
|
|
3222
3909
|
button: HTMLButtonElement,
|
|
3223
3910
|
choice: QtiChoice,
|
|
@@ -3545,6 +4232,25 @@ function polylineElement(points: DrawingPoint[]): SVGPolylineElement {
|
|
|
3545
4232
|
return line;
|
|
3546
4233
|
}
|
|
3547
4234
|
|
|
4235
|
+
function portableCustomDefinitionFromAttributes(
|
|
4236
|
+
interaction: QtiInteraction,
|
|
4237
|
+
): QtiPortableCustomDefinition {
|
|
4238
|
+
return {
|
|
4239
|
+
responseIdentifier: interaction.responseIdentifier,
|
|
4240
|
+
customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
|
|
4241
|
+
module: interaction.attributes.module,
|
|
4242
|
+
interactionMarkup: [],
|
|
4243
|
+
templateVariables: [],
|
|
4244
|
+
contextVariables: [],
|
|
4245
|
+
stylesheets: [],
|
|
4246
|
+
dataAttributes: Object.fromEntries(
|
|
4247
|
+
Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-")),
|
|
4248
|
+
),
|
|
4249
|
+
attributes: interaction.attributes,
|
|
4250
|
+
source: interaction.source,
|
|
4251
|
+
};
|
|
4252
|
+
}
|
|
4253
|
+
|
|
3548
4254
|
function portableCustomEventValue(event: Event): QtiValue | undefined {
|
|
3549
4255
|
if (!("detail" in event)) return undefined;
|
|
3550
4256
|
const detail = event.detail as { value?: QtiValue; response?: QtiValue } | QtiValue | undefined;
|
|
@@ -3552,14 +4258,49 @@ function portableCustomEventValue(event: Event): QtiValue | undefined {
|
|
|
3552
4258
|
if (typeof detail === "object" && detail !== null && !Array.isArray(detail)) {
|
|
3553
4259
|
if ("value" in detail) return detail.value ?? null;
|
|
3554
4260
|
if ("response" in detail) return detail.response ?? null;
|
|
4261
|
+
if ("state" in detail || "valid" in detail) return undefined;
|
|
3555
4262
|
}
|
|
3556
4263
|
return detail as QtiValue;
|
|
3557
4264
|
}
|
|
3558
4265
|
|
|
4266
|
+
function portableCustomEventState(event: Event): QtiPortableCustomStateValue | undefined {
|
|
4267
|
+
if (!("detail" in event)) return undefined;
|
|
4268
|
+
const detail = event.detail as { state?: unknown } | undefined;
|
|
4269
|
+
if (typeof detail !== "object" || detail === null || !("state" in detail)) return undefined;
|
|
4270
|
+
return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4273
|
+
function portableCustomEventValidity(
|
|
4274
|
+
event: Event,
|
|
4275
|
+
): { valid: boolean; message?: string | undefined } | undefined {
|
|
4276
|
+
if (!("detail" in event)) return undefined;
|
|
4277
|
+
const detail = event.detail as { valid?: unknown; message?: unknown } | undefined;
|
|
4278
|
+
if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
|
|
4279
|
+
return undefined;
|
|
4280
|
+
}
|
|
4281
|
+
return {
|
|
4282
|
+
valid: detail.valid,
|
|
4283
|
+
message: typeof detail.message === "string" ? detail.message : undefined,
|
|
4284
|
+
};
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
function isPortableCustomStateValue(value: unknown): value is QtiPortableCustomStateValue {
|
|
4288
|
+
if (value === null) return true;
|
|
4289
|
+
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
4290
|
+
if (typeof value === "number") return Number.isFinite(value);
|
|
4291
|
+
if (Array.isArray(value)) return value.every(isPortableCustomStateValue);
|
|
4292
|
+
if (typeof value === "object") {
|
|
4293
|
+
return Object.values(value as Record<string, unknown>).every(isPortableCustomStateValue);
|
|
4294
|
+
}
|
|
4295
|
+
return false;
|
|
4296
|
+
}
|
|
4297
|
+
|
|
3559
4298
|
const htmlContentElements = new Set([
|
|
3560
4299
|
"a",
|
|
3561
4300
|
"abbr",
|
|
3562
4301
|
"b",
|
|
4302
|
+
"bdi",
|
|
4303
|
+
"bdo",
|
|
3563
4304
|
"blockquote",
|
|
3564
4305
|
"br",
|
|
3565
4306
|
"caption",
|
|
@@ -3573,6 +4314,12 @@ const htmlContentElements = new Set([
|
|
|
3573
4314
|
"em",
|
|
3574
4315
|
"figcaption",
|
|
3575
4316
|
"figure",
|
|
4317
|
+
"h1",
|
|
4318
|
+
"h2",
|
|
4319
|
+
"h3",
|
|
4320
|
+
"h4",
|
|
4321
|
+
"h5",
|
|
4322
|
+
"h6",
|
|
3576
4323
|
"hr",
|
|
3577
4324
|
"i",
|
|
3578
4325
|
"img",
|
|
@@ -3582,6 +4329,12 @@ const htmlContentElements = new Set([
|
|
|
3582
4329
|
"p",
|
|
3583
4330
|
"pre",
|
|
3584
4331
|
"q",
|
|
4332
|
+
"rb",
|
|
4333
|
+
"rbc",
|
|
4334
|
+
"rp",
|
|
4335
|
+
"rt",
|
|
4336
|
+
"rtc",
|
|
4337
|
+
"ruby",
|
|
3585
4338
|
"samp",
|
|
3586
4339
|
"small",
|
|
3587
4340
|
"span",
|
|
@@ -3599,6 +4352,8 @@ const htmlContentElements = new Set([
|
|
|
3599
4352
|
"var",
|
|
3600
4353
|
]);
|
|
3601
4354
|
|
|
4355
|
+
const unsafeContentElements = new Set(["script", "style"]);
|
|
4356
|
+
|
|
3602
4357
|
const mathMlElements = new Set([
|
|
3603
4358
|
"math",
|
|
3604
4359
|
"maction",
|
|
@@ -3663,32 +4418,89 @@ function copySafeAttributes(element: Element, attributes: Record<string, string>
|
|
|
3663
4418
|
for (const [name, value] of Object.entries(attributes)) {
|
|
3664
4419
|
if (!isSafeContentAttribute(name, value)) continue;
|
|
3665
4420
|
element.setAttribute(name, value);
|
|
4421
|
+
if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
|
|
4422
|
+
element.setAttribute("lang", value);
|
|
4423
|
+
}
|
|
3666
4424
|
}
|
|
4425
|
+
applySharedAccessibilityVocabulary(element, attributes);
|
|
4426
|
+
}
|
|
4427
|
+
|
|
4428
|
+
function applySharedAccessibilityVocabulary(
|
|
4429
|
+
element: Element,
|
|
4430
|
+
attributes: Record<string, string>,
|
|
4431
|
+
): void {
|
|
4432
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
4433
|
+
const ariaName = qtiAriaAttributeName(name);
|
|
4434
|
+
if (!ariaName || hasAttributeName(attributes, ariaName)) continue;
|
|
4435
|
+
element.setAttribute(ariaName, value);
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
|
|
4439
|
+
if (
|
|
4440
|
+
suppressesScreenReaderSpeech(suppressTts) &&
|
|
4441
|
+
!hasAttributeName(attributes, "aria-hidden") &&
|
|
4442
|
+
!hasAttributeName(attributes, "data-qti-aria-hidden")
|
|
4443
|
+
) {
|
|
4444
|
+
element.setAttribute("aria-hidden", "true");
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
|
|
4448
|
+
function qtiAriaAttributeName(name: string): string | undefined {
|
|
4449
|
+
const normalizedName = name.toLowerCase();
|
|
4450
|
+
const prefix = "data-qti-aria-";
|
|
4451
|
+
if (!normalizedName.startsWith(prefix)) return undefined;
|
|
4452
|
+
const suffix = normalizedName.slice(prefix.length);
|
|
4453
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix)) return undefined;
|
|
4454
|
+
return `aria-${suffix}`;
|
|
4455
|
+
}
|
|
4456
|
+
|
|
4457
|
+
function attributeValue(attributes: Record<string, string>, name: string): string | undefined {
|
|
4458
|
+
const normalizedName = name.toLowerCase();
|
|
4459
|
+
const entry = Object.entries(attributes).find(
|
|
4460
|
+
([attributeName]) => attributeName.toLowerCase() === normalizedName,
|
|
4461
|
+
);
|
|
4462
|
+
return entry?.[1];
|
|
4463
|
+
}
|
|
4464
|
+
|
|
4465
|
+
function hasAttributeName(attributes: Record<string, string>, name: string): boolean {
|
|
4466
|
+
return attributeValue(attributes, name) !== undefined;
|
|
4467
|
+
}
|
|
4468
|
+
|
|
4469
|
+
function suppressesScreenReaderSpeech(value: string | undefined): boolean {
|
|
4470
|
+
if (!value) return false;
|
|
4471
|
+
const tokens = value
|
|
4472
|
+
.toLowerCase()
|
|
4473
|
+
.split(/[\s,]+/)
|
|
4474
|
+
.filter(Boolean);
|
|
4475
|
+
return tokens.includes("all") || tokens.includes("screen-reader");
|
|
3667
4476
|
}
|
|
3668
4477
|
|
|
3669
4478
|
function isSafeContentAttribute(name: string, value: string): boolean {
|
|
3670
|
-
|
|
3671
|
-
if (
|
|
3672
|
-
if (
|
|
4479
|
+
const normalizedName = name.toLowerCase();
|
|
4480
|
+
if (normalizedName.startsWith("on")) return false;
|
|
4481
|
+
if (normalizedName === "style") return false;
|
|
4482
|
+
if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
|
|
3673
4483
|
return isSafeUrl(value);
|
|
3674
4484
|
}
|
|
3675
4485
|
return (
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
4486
|
+
normalizedName === "alt" ||
|
|
4487
|
+
normalizedName === "class" ||
|
|
4488
|
+
normalizedName === "colspan" ||
|
|
4489
|
+
normalizedName === "dir" ||
|
|
4490
|
+
normalizedName === "headers" ||
|
|
4491
|
+
normalizedName === "height" ||
|
|
4492
|
+
normalizedName === "id" ||
|
|
4493
|
+
normalizedName === "lang" ||
|
|
4494
|
+
normalizedName === "role" ||
|
|
4495
|
+
normalizedName === "rowspan" ||
|
|
4496
|
+
normalizedName === "scope" ||
|
|
4497
|
+
normalizedName === "title" ||
|
|
4498
|
+
normalizedName === "type" ||
|
|
4499
|
+
normalizedName === "width" ||
|
|
4500
|
+
normalizedName === "xml:lang" ||
|
|
4501
|
+
mathMlAttributeNames.has(normalizedName) ||
|
|
4502
|
+
normalizedName.startsWith("aria-") ||
|
|
4503
|
+
normalizedName.startsWith("data-")
|
|
3692
4504
|
);
|
|
3693
4505
|
}
|
|
3694
4506
|
|
|
@@ -3834,6 +4646,23 @@ function playerStyleElement(): HTMLStyleElement {
|
|
|
3834
4646
|
margin-block: 0;
|
|
3835
4647
|
}
|
|
3836
4648
|
|
|
4649
|
+
.qti3-player .qti-hidden {
|
|
4650
|
+
display: none !important;
|
|
4651
|
+
}
|
|
4652
|
+
|
|
4653
|
+
.qti3-player .qti-visually-hidden {
|
|
4654
|
+
position: absolute !important;
|
|
4655
|
+
overflow: hidden !important;
|
|
4656
|
+
clip: rect(1px, 1px, 1px, 1px) !important;
|
|
4657
|
+
clip-path: inset(50%) !important;
|
|
4658
|
+
inline-size: 1px !important;
|
|
4659
|
+
block-size: 1px !important;
|
|
4660
|
+
margin: -1px !important;
|
|
4661
|
+
padding: 0 !important;
|
|
4662
|
+
border: 0 !important;
|
|
4663
|
+
white-space: nowrap !important;
|
|
4664
|
+
}
|
|
4665
|
+
|
|
3837
4666
|
.qti3-embedded-interaction {
|
|
3838
4667
|
display: inline-flex;
|
|
3839
4668
|
gap: 0.35rem;
|
|
@@ -4058,6 +4887,37 @@ function playerStyleElement(): HTMLStyleElement {
|
|
|
4058
4887
|
line-height: 1;
|
|
4059
4888
|
}
|
|
4060
4889
|
|
|
4890
|
+
.qti3-remove-button {
|
|
4891
|
+
border: 1px solid currentColor;
|
|
4892
|
+
background: transparent;
|
|
4893
|
+
color: inherit;
|
|
4894
|
+
cursor: pointer;
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
.qti3-remove-button:hover {
|
|
4898
|
+
background: color-mix(in srgb, currentColor 14%, transparent);
|
|
4899
|
+
}
|
|
4900
|
+
|
|
4901
|
+
.qti3-trash-icon {
|
|
4902
|
+
inline-size: 1.125rem;
|
|
4903
|
+
block-size: 1.125rem;
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
.qti3-movement-icon {
|
|
4907
|
+
inline-size: 1rem;
|
|
4908
|
+
block-size: 1rem;
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4911
|
+
.qti3-trash-icon path,
|
|
4912
|
+
.qti3-movement-icon path {
|
|
4913
|
+
fill: none;
|
|
4914
|
+
stroke: currentColor;
|
|
4915
|
+
stroke-width: 2;
|
|
4916
|
+
stroke-linecap: round;
|
|
4917
|
+
stroke-linejoin: round;
|
|
4918
|
+
vector-effect: non-scaling-stroke;
|
|
4919
|
+
}
|
|
4920
|
+
|
|
4061
4921
|
.qti3-token[aria-pressed="true"],
|
|
4062
4922
|
.qti3-pair-chip {
|
|
4063
4923
|
background: Highlight;
|
|
@@ -4206,6 +5066,7 @@ function playerStyleElement(): HTMLStyleElement {
|
|
|
4206
5066
|
}
|
|
4207
5067
|
|
|
4208
5068
|
.qti3-graphic-associate-surface,
|
|
5069
|
+
.qti3-graphic-gap-match-surface,
|
|
4209
5070
|
.qti3-graphic-order-surface {
|
|
4210
5071
|
touch-action: manipulation;
|
|
4211
5072
|
}
|
|
@@ -4233,10 +5094,60 @@ function playerStyleElement(): HTMLStyleElement {
|
|
|
4233
5094
|
}
|
|
4234
5095
|
|
|
4235
5096
|
.qti3-graphic-associate-hotspot,
|
|
5097
|
+
.qti3-graphic-gap-hotspot,
|
|
4236
5098
|
.qti3-graphic-order-hotspot {
|
|
4237
5099
|
z-index: 2;
|
|
4238
5100
|
}
|
|
4239
5101
|
|
|
5102
|
+
.qti3-graphic-gap-match-surface {
|
|
5103
|
+
margin-block-end: calc(var(--qti3-graphic-gap-label-block-size, 2rem) + 0.75rem);
|
|
5104
|
+
}
|
|
5105
|
+
|
|
5106
|
+
.qti3-graphic-gap-hotspot {
|
|
5107
|
+
display: grid;
|
|
5108
|
+
place-items: center;
|
|
5109
|
+
padding: 0;
|
|
5110
|
+
overflow: visible;
|
|
5111
|
+
border-style: dashed;
|
|
5112
|
+
background: rgb(255 255 255 / 0.08);
|
|
5113
|
+
color: CanvasText;
|
|
5114
|
+
}
|
|
5115
|
+
|
|
5116
|
+
.qti3-graphic-gap-hotspot[data-selected="true"] {
|
|
5117
|
+
border-style: solid;
|
|
5118
|
+
background: color-mix(in srgb, Highlight 18%, Canvas);
|
|
5119
|
+
}
|
|
5120
|
+
|
|
5121
|
+
.qti3-graphic-gap-label {
|
|
5122
|
+
position: absolute;
|
|
5123
|
+
inset-block-start: calc(100% + 0.2rem);
|
|
5124
|
+
inset-inline-start: 50%;
|
|
5125
|
+
transform: translateX(-50%);
|
|
5126
|
+
box-sizing: border-box;
|
|
5127
|
+
inline-size: max-content;
|
|
5128
|
+
max-inline-size: min(12rem, calc(100vw - 2rem));
|
|
5129
|
+
min-inline-size: 0;
|
|
5130
|
+
padding: 0.25rem 0.4rem;
|
|
5131
|
+
border: 1px solid CanvasText;
|
|
5132
|
+
border-radius: 0.25rem;
|
|
5133
|
+
background: Canvas;
|
|
5134
|
+
color: CanvasText;
|
|
5135
|
+
font-size: 0.75rem;
|
|
5136
|
+
font-weight: 700;
|
|
5137
|
+
line-height: 1.15;
|
|
5138
|
+
overflow-wrap: anywhere;
|
|
5139
|
+
pointer-events: none;
|
|
5140
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
|
|
5141
|
+
text-align: center;
|
|
5142
|
+
white-space: normal;
|
|
5143
|
+
}
|
|
5144
|
+
|
|
5145
|
+
@supports not (background: color-mix(in srgb, Highlight 18%, Canvas)) {
|
|
5146
|
+
.qti3-graphic-gap-hotspot[data-selected="true"] {
|
|
5147
|
+
background: Canvas;
|
|
5148
|
+
}
|
|
5149
|
+
}
|
|
5150
|
+
|
|
4240
5151
|
.qti3-graphic-order-hotspot {
|
|
4241
5152
|
display: grid;
|
|
4242
5153
|
place-items: center;
|