@longsightgroup/qti3-player 0.1.0 → 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/dist/index.d.ts +42 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +677 -239
- package/dist/index.js.map +1 -1
- package/package.json +6 -4
- package/src/index.ts +4396 -0
package/dist/index.js
CHANGED
|
@@ -35,33 +35,36 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
35
35
|
this.render();
|
|
36
36
|
this.renderValidationMessages();
|
|
37
37
|
this.updateAttemptAvailability();
|
|
38
|
-
this.
|
|
38
|
+
this.dispatchPlayerEvent("qti-ready", { item: result.document.item });
|
|
39
39
|
this.emitStateChange();
|
|
40
40
|
}
|
|
41
41
|
async loadUrl(url, options = {}) {
|
|
42
42
|
const fetchXml = options.fetchXml ?? defaultFetchXml;
|
|
43
43
|
await this.loadXml(await fetchXml(url), options);
|
|
44
44
|
}
|
|
45
|
-
scoreAttempt() {
|
|
45
|
+
scoreAttempt(options = {}) {
|
|
46
46
|
const session = this.session;
|
|
47
47
|
if (!session)
|
|
48
48
|
return undefined;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
: [];
|
|
49
|
+
const shouldValidateResponses = options.validateResponses ?? this.sessionControl.validateResponses;
|
|
50
|
+
const validationMessages = shouldValidateResponses ? this.validateResponses() : [];
|
|
52
51
|
if (validationMessages.length > 0) {
|
|
53
|
-
this.validationMessages = validationMessages;
|
|
52
|
+
this.validationMessages = cloneDiagnostics(validationMessages);
|
|
54
53
|
this.renderValidationMessages();
|
|
55
|
-
const state =
|
|
56
|
-
state
|
|
57
|
-
|
|
54
|
+
const state = this.serialize();
|
|
55
|
+
if (!state)
|
|
56
|
+
return undefined;
|
|
57
|
+
this.dispatchPlayerEvent("qti-validation", {
|
|
58
|
+
validationMessages: cloneDiagnostics(this.validationMessages),
|
|
59
|
+
state,
|
|
60
|
+
});
|
|
58
61
|
this.emitStateChange(state);
|
|
59
62
|
return undefined;
|
|
60
63
|
}
|
|
61
64
|
this.validationMessages = [];
|
|
62
65
|
this.renderValidationMessages();
|
|
63
66
|
const result = session.score();
|
|
64
|
-
this.
|
|
67
|
+
this.dispatchPlayerEvent("qti-score", result);
|
|
65
68
|
this.updateDynamicBodyState();
|
|
66
69
|
this.updateAttemptAvailability();
|
|
67
70
|
if (this.sessionControl.showFeedback)
|
|
@@ -96,12 +99,17 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
96
99
|
this.emitStateChange();
|
|
97
100
|
}
|
|
98
101
|
suspend() {
|
|
99
|
-
this.session
|
|
100
|
-
|
|
101
|
-
this.
|
|
102
|
+
if (!this.session)
|
|
103
|
+
return;
|
|
104
|
+
this.session.setStatus("suspended");
|
|
105
|
+
const state = this.serialize();
|
|
106
|
+
if (!state)
|
|
107
|
+
return;
|
|
108
|
+
this.dispatchPlayerEvent("qti-suspend", { state });
|
|
109
|
+
this.emitStateChange(state);
|
|
102
110
|
}
|
|
103
|
-
endAttempt() {
|
|
104
|
-
const result = this.scoreAttempt();
|
|
111
|
+
endAttempt(options = {}) {
|
|
112
|
+
const result = this.scoreAttempt(options);
|
|
105
113
|
if (!result)
|
|
106
114
|
return;
|
|
107
115
|
if (!this.documentModel?.item.adaptive ||
|
|
@@ -109,8 +117,11 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
109
117
|
this.session?.setStatus("completed");
|
|
110
118
|
}
|
|
111
119
|
this.updateAttemptAvailability();
|
|
112
|
-
|
|
113
|
-
|
|
120
|
+
const state = this.serialize();
|
|
121
|
+
if (!state)
|
|
122
|
+
return;
|
|
123
|
+
this.dispatchPlayerEvent("qti-endattempt", { state });
|
|
124
|
+
this.emitStateChange(state);
|
|
114
125
|
}
|
|
115
126
|
serialize() {
|
|
116
127
|
const state = this.session?.serialize();
|
|
@@ -119,7 +130,12 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
119
130
|
return state;
|
|
120
131
|
}
|
|
121
132
|
emitStateChange(state = this.serialize()) {
|
|
122
|
-
|
|
133
|
+
if (!state)
|
|
134
|
+
return;
|
|
135
|
+
this.dispatchPlayerEvent("qti-statechange", { state });
|
|
136
|
+
}
|
|
137
|
+
dispatchPlayerEvent(type, detail) {
|
|
138
|
+
this.dispatchEvent(new CustomEvent(type, { detail }));
|
|
123
139
|
}
|
|
124
140
|
render() {
|
|
125
141
|
const documentModel = this.documentModel;
|
|
@@ -128,12 +144,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
128
144
|
this.applyDefaultStyles();
|
|
129
145
|
const root = document.createElement("article");
|
|
130
146
|
root.className = "qti3-player";
|
|
131
|
-
root.setAttribute("aria-labelledby", "qti3-item-title");
|
|
132
147
|
root.append(playerStyleElement());
|
|
133
|
-
const title = document.createElement("h2");
|
|
134
|
-
title.id = "qti3-item-title";
|
|
135
|
-
title.textContent = documentModel.item.title ?? documentModel.item.identifier;
|
|
136
|
-
root.append(title);
|
|
137
148
|
if (documentModel.item.prompt && documentModel.item.body.length === 0) {
|
|
138
149
|
const prompt = document.createElement("p");
|
|
139
150
|
prompt.className = "qti3-item-prompt";
|
|
@@ -151,14 +162,6 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
151
162
|
root.append(this.renderInteraction(interaction));
|
|
152
163
|
}
|
|
153
164
|
}
|
|
154
|
-
const actions = document.createElement("div");
|
|
155
|
-
actions.className = "qti3-actions";
|
|
156
|
-
const score = document.createElement("button");
|
|
157
|
-
score.type = "button";
|
|
158
|
-
score.textContent = "Score";
|
|
159
|
-
score.addEventListener("click", () => this.scoreAttempt());
|
|
160
|
-
actions.append(score);
|
|
161
|
-
root.append(actions);
|
|
162
165
|
const feedback = document.createElement("section");
|
|
163
166
|
feedback.className = "qti3-feedback";
|
|
164
167
|
feedback.role = "status";
|
|
@@ -189,9 +192,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
189
192
|
return;
|
|
190
193
|
this.session.respond(responseIdentifier, value);
|
|
191
194
|
this.clearValidationMessage(responseIdentifier);
|
|
192
|
-
this.
|
|
193
|
-
detail: { responseIdentifier, value },
|
|
194
|
-
}));
|
|
195
|
+
this.dispatchPlayerEvent("qti-responsechange", { responseIdentifier, value });
|
|
195
196
|
this.emitStateChange();
|
|
196
197
|
};
|
|
197
198
|
const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
|
|
@@ -284,7 +285,11 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
284
285
|
return field;
|
|
285
286
|
}
|
|
286
287
|
if (interaction.type === "media") {
|
|
287
|
-
field.append(renderObjectAsset(interaction
|
|
288
|
+
field.append(renderObjectAsset(interaction, {
|
|
289
|
+
currentValue,
|
|
290
|
+
update,
|
|
291
|
+
isCompleted: () => this.attemptIsCompleted(),
|
|
292
|
+
}));
|
|
288
293
|
return field;
|
|
289
294
|
}
|
|
290
295
|
field.append(renderSelect(interaction, update, currentValue));
|
|
@@ -307,9 +312,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
307
312
|
return;
|
|
308
313
|
this.session.respond(responseIdentifier, value);
|
|
309
314
|
this.clearValidationMessage(responseIdentifier);
|
|
310
|
-
this.
|
|
311
|
-
detail: { responseIdentifier, value },
|
|
312
|
-
}));
|
|
315
|
+
this.dispatchPlayerEvent("qti-responsechange", { responseIdentifier, value });
|
|
313
316
|
this.emitStateChange();
|
|
314
317
|
};
|
|
315
318
|
const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
|
|
@@ -421,7 +424,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
421
424
|
const article = this.querySelector(".qti3-player");
|
|
422
425
|
if (article)
|
|
423
426
|
article.dataset.status = this.dataset.status;
|
|
424
|
-
for (const control of this.querySelectorAll(".qti3-interaction button, .qti3-interaction input, .qti3-interaction select, .qti3-interaction textarea
|
|
427
|
+
for (const control of this.querySelectorAll(".qti3-interaction button, .qti3-interaction input, .qti3-interaction select, .qti3-interaction textarea")) {
|
|
425
428
|
control.disabled = completed;
|
|
426
429
|
}
|
|
427
430
|
for (const element of this.querySelectorAll(".qti3-interaction [tabindex]:not(button):not(input):not(select):not(textarea)")) {
|
|
@@ -512,20 +515,24 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
512
515
|
.map((interaction) => [interaction.responseIdentifier, interaction]));
|
|
513
516
|
const diagnostics = [];
|
|
514
517
|
for (const declaration of this.documentModel.item.responseDeclarations) {
|
|
515
|
-
if (declaration.correctResponse === null)
|
|
516
|
-
continue;
|
|
517
518
|
const interaction = interactionsByResponse.get(declaration.identifier);
|
|
519
|
+
if (declaration.correctResponse === null && interaction?.type !== "media")
|
|
520
|
+
continue;
|
|
518
521
|
const minimum = minimumRequiredResponses(interaction);
|
|
519
|
-
const count =
|
|
522
|
+
const count = interaction?.type === "media"
|
|
523
|
+
? mediaPlayCount(state.responses[declaration.identifier] ?? null)
|
|
524
|
+
: responseCount(state.responses[declaration.identifier] ?? null);
|
|
520
525
|
const maximum = maximumAllowedResponses(interaction);
|
|
521
526
|
if (count < minimum) {
|
|
522
527
|
diagnostics.push({
|
|
523
528
|
code: "response.required",
|
|
524
529
|
severity: "error",
|
|
525
530
|
message: interaction?.attributes["data-min-selections-message"] ??
|
|
526
|
-
(
|
|
527
|
-
? `${declaration.identifier} requires
|
|
528
|
-
:
|
|
531
|
+
(interaction?.type === "media"
|
|
532
|
+
? `${declaration.identifier} requires at least ${minimum} play${minimum === 1 ? "" : "s"}.`
|
|
533
|
+
: minimum === 1
|
|
534
|
+
? `${declaration.identifier} requires a response.`
|
|
535
|
+
: `${declaration.identifier} requires at least ${minimum} responses.`),
|
|
529
536
|
path: declaration.identifier,
|
|
530
537
|
});
|
|
531
538
|
}
|
|
@@ -534,7 +541,9 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
534
541
|
code: "response.maximum",
|
|
535
542
|
severity: "error",
|
|
536
543
|
message: interaction?.attributes["data-max-selections-message"] ??
|
|
537
|
-
|
|
544
|
+
(interaction?.type === "media"
|
|
545
|
+
? `${declaration.identifier} allows at most ${maximum} play${maximum === 1 ? "" : "s"}.`
|
|
546
|
+
: `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`),
|
|
538
547
|
path: declaration.identifier,
|
|
539
548
|
});
|
|
540
549
|
}
|
|
@@ -600,11 +609,7 @@ export function defineQtiAssessmentItemPlayer() {
|
|
|
600
609
|
}
|
|
601
610
|
}
|
|
602
611
|
function renderChoice(interaction, update, currentValue) {
|
|
603
|
-
const group =
|
|
604
|
-
group.className = "qti3-choice-group";
|
|
605
|
-
const legend = document.createElement("legend");
|
|
606
|
-
legend.textContent = readableType(interaction.type);
|
|
607
|
-
group.append(legend);
|
|
612
|
+
const group = responseGroup("qti3-choice-group");
|
|
608
613
|
const multiple = interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
|
|
609
614
|
const selected = new Set(valueToStrings(currentValue));
|
|
610
615
|
const list = document.createElement("div");
|
|
@@ -661,6 +666,30 @@ function renderChoice(interaction, update, currentValue) {
|
|
|
661
666
|
group.append(list);
|
|
662
667
|
return group;
|
|
663
668
|
}
|
|
669
|
+
function responseGroup(className) {
|
|
670
|
+
const group = document.createElement("div");
|
|
671
|
+
group.className = ["qti3-response-group", className].filter(Boolean).join(" ");
|
|
672
|
+
return group;
|
|
673
|
+
}
|
|
674
|
+
const movementGlyphs = {
|
|
675
|
+
up: "\u2191",
|
|
676
|
+
down: "\u2193",
|
|
677
|
+
left: "\u2190",
|
|
678
|
+
right: "\u2192",
|
|
679
|
+
};
|
|
680
|
+
function movementButton(direction, accessibleName, onClick) {
|
|
681
|
+
const button = document.createElement("button");
|
|
682
|
+
button.type = "button";
|
|
683
|
+
button.className = "qti3-icon-button";
|
|
684
|
+
button.dataset.moveDirection = direction;
|
|
685
|
+
button.textContent = movementGlyphs[direction];
|
|
686
|
+
button.setAttribute("aria-label", accessibleName);
|
|
687
|
+
button.addEventListener("click", onClick);
|
|
688
|
+
return button;
|
|
689
|
+
}
|
|
690
|
+
function movementLabel(target, direction) {
|
|
691
|
+
return `Move ${target} ${direction}`;
|
|
692
|
+
}
|
|
664
693
|
function renderHottextResponse(interaction, update, currentValue) {
|
|
665
694
|
const group = document.createElement("div");
|
|
666
695
|
group.className = "qti3-hottext-group";
|
|
@@ -757,10 +786,7 @@ function usesPairResponse(interaction) {
|
|
|
757
786
|
interaction.type === "graphicGapMatch");
|
|
758
787
|
}
|
|
759
788
|
function renderOrderedResponse(interaction, update, currentValue) {
|
|
760
|
-
const group =
|
|
761
|
-
const legend = document.createElement("legend");
|
|
762
|
-
legend.textContent = orderedResponseLegend(interaction.type);
|
|
763
|
-
group.append(legend);
|
|
789
|
+
const group = responseGroup();
|
|
764
790
|
appendGraphicContext(group, interaction);
|
|
765
791
|
const choices = choicesOrFallback(interaction).filter((choice) => choice.role !== "gap");
|
|
766
792
|
const ordered = orderChoicesFromValue(choices, currentValue);
|
|
@@ -850,20 +876,10 @@ function renderOrderedResponse(interaction, update, currentValue) {
|
|
|
850
876
|
moveChoice(index, index + 1);
|
|
851
877
|
}
|
|
852
878
|
});
|
|
853
|
-
const up =
|
|
854
|
-
up.type = "button";
|
|
855
|
-
up.className = "qti3-icon-button";
|
|
856
|
-
up.textContent = "Up";
|
|
879
|
+
const up = movementButton("up", movementLabel(choice.text, "up"), () => moveChoice(index, index - 1));
|
|
857
880
|
up.disabled = index === 0;
|
|
858
|
-
|
|
859
|
-
up.addEventListener("click", () => moveChoice(index, index - 1));
|
|
860
|
-
const down = document.createElement("button");
|
|
861
|
-
down.type = "button";
|
|
862
|
-
down.className = "qti3-icon-button";
|
|
863
|
-
down.textContent = "Down";
|
|
881
|
+
const down = movementButton("down", movementLabel(choice.text, "down"), () => moveChoice(index, index + 1));
|
|
864
882
|
down.disabled = index === ordered.length - 1;
|
|
865
|
-
down.setAttribute("aria-label", `Move ${choice.text} down`);
|
|
866
|
-
down.addEventListener("click", () => moveChoice(index, index + 1));
|
|
867
883
|
item.append(handle, up, down);
|
|
868
884
|
return item;
|
|
869
885
|
}));
|
|
@@ -873,10 +889,7 @@ function renderOrderedResponse(interaction, update, currentValue) {
|
|
|
873
889
|
return group;
|
|
874
890
|
}
|
|
875
891
|
function renderPairResponse(interaction, update, currentValue) {
|
|
876
|
-
const group =
|
|
877
|
-
const legend = document.createElement("legend");
|
|
878
|
-
legend.textContent = `${readableType(interaction.type)} pairs`;
|
|
879
|
-
group.append(legend);
|
|
892
|
+
const group = responseGroup();
|
|
880
893
|
appendGraphicContext(group, interaction);
|
|
881
894
|
const sources = sourceChoices(interaction);
|
|
882
895
|
const targets = targetChoices(interaction);
|
|
@@ -993,17 +1006,19 @@ function renderPairResponse(interaction, update, currentValue) {
|
|
|
993
1006
|
return group;
|
|
994
1007
|
}
|
|
995
1008
|
function renderMatchResponse(interaction, update, currentValue) {
|
|
996
|
-
const group =
|
|
997
|
-
const legend = document.createElement("legend");
|
|
998
|
-
legend.textContent = "Match rows";
|
|
999
|
-
group.append(legend);
|
|
1009
|
+
const group = responseGroup();
|
|
1000
1010
|
const sources = sourceChoices(interaction);
|
|
1001
1011
|
const targets = targetChoices(interaction);
|
|
1002
1012
|
const selectedPairs = valueToStrings(currentValue);
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1013
|
+
let selectedSource;
|
|
1014
|
+
let selectedTarget;
|
|
1015
|
+
let draggedSource;
|
|
1016
|
+
const selector = document.createElement("div");
|
|
1017
|
+
selector.className = "qti3-match-selector";
|
|
1018
|
+
const sourceRegion = tokenRegion("Match sources");
|
|
1019
|
+
sourceRegion.classList.add("qti3-match-source-bank");
|
|
1020
|
+
const targetRegion = tokenRegion("Match targets");
|
|
1021
|
+
targetRegion.classList.add("qti3-match-target-bank");
|
|
1007
1022
|
const pairList = document.createElement("ul");
|
|
1008
1023
|
pairList.className = "qti3-pair-list";
|
|
1009
1024
|
pairList.setAttribute("aria-label", "Match selected pairs");
|
|
@@ -1013,17 +1028,27 @@ function renderMatchResponse(interaction, update, currentValue) {
|
|
|
1013
1028
|
else
|
|
1014
1029
|
update([...selectedPairs]);
|
|
1015
1030
|
};
|
|
1016
|
-
const syncPressed = () => {
|
|
1017
|
-
for (const button of grid.querySelectorAll(".qti3-match-target")) {
|
|
1018
|
-
const pair = `${button.dataset.sourceIdentifier} ${button.dataset.choiceIdentifier}`;
|
|
1019
|
-
button.setAttribute("aria-pressed", selectedPairs.includes(pair) ? "true" : "false");
|
|
1020
|
-
}
|
|
1021
|
-
};
|
|
1022
1031
|
const removePair = (pair) => {
|
|
1023
1032
|
const index = selectedPairs.indexOf(pair);
|
|
1024
1033
|
if (index >= 0)
|
|
1025
1034
|
selectedPairs.splice(index, 1);
|
|
1026
1035
|
};
|
|
1036
|
+
const syncPressed = () => {
|
|
1037
|
+
for (const button of sourceRegion.querySelectorAll("button")) {
|
|
1038
|
+
const identifier = button.dataset.choiceIdentifier ?? "";
|
|
1039
|
+
button.setAttribute("aria-pressed", identifier === selectedSource?.identifier ||
|
|
1040
|
+
selectedPairs.some((pair) => pair.startsWith(`${identifier} `))
|
|
1041
|
+
? "true"
|
|
1042
|
+
: "false");
|
|
1043
|
+
}
|
|
1044
|
+
for (const button of targetRegion.querySelectorAll("button")) {
|
|
1045
|
+
const identifier = button.dataset.choiceIdentifier ?? "";
|
|
1046
|
+
button.setAttribute("aria-pressed", identifier === selectedTarget?.identifier ||
|
|
1047
|
+
selectedPairs.some((pair) => pair.endsWith(` ${identifier}`))
|
|
1048
|
+
? "true"
|
|
1049
|
+
: "false");
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1027
1052
|
const renderPairs = () => {
|
|
1028
1053
|
pairList.replaceChildren(...selectedPairs.map((pair) => {
|
|
1029
1054
|
const [source, target] = pair.split(" ");
|
|
@@ -1046,53 +1071,117 @@ function renderMatchResponse(interaction, update, currentValue) {
|
|
|
1046
1071
|
return item;
|
|
1047
1072
|
}));
|
|
1048
1073
|
};
|
|
1074
|
+
const clearSelection = () => {
|
|
1075
|
+
selectedSource = undefined;
|
|
1076
|
+
selectedTarget = undefined;
|
|
1077
|
+
};
|
|
1078
|
+
const removePairsForSource = (source) => {
|
|
1079
|
+
for (const existing of selectedPairs.filter((pair) => pair.startsWith(`${source.identifier} `))) {
|
|
1080
|
+
removePair(existing);
|
|
1081
|
+
}
|
|
1082
|
+
};
|
|
1083
|
+
const removePairsForTarget = (target) => {
|
|
1084
|
+
for (const existing of selectedPairs.filter((pair) => pair.endsWith(` ${target.identifier}`))) {
|
|
1085
|
+
removePair(existing);
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1049
1088
|
const togglePair = (source, target) => {
|
|
1050
1089
|
const pair = `${source.identifier} ${target.identifier}`;
|
|
1051
1090
|
if (selectedPairs.includes(pair)) {
|
|
1052
1091
|
removePair(pair);
|
|
1053
1092
|
}
|
|
1054
1093
|
else {
|
|
1094
|
+
if (interaction.responseCardinality === "single")
|
|
1095
|
+
selectedPairs.splice(0);
|
|
1055
1096
|
if (parseUnlimitedMaximum(source.attributes["match-max"]) === 1) {
|
|
1056
|
-
|
|
1057
|
-
for (const existing of existingSourcePairs)
|
|
1058
|
-
removePair(existing);
|
|
1097
|
+
removePairsForSource(source);
|
|
1059
1098
|
}
|
|
1060
1099
|
if (parseUnlimitedMaximum(target.attributes["match-max"]) === 1) {
|
|
1061
|
-
|
|
1062
|
-
for (const existing of existingTargetPairs)
|
|
1063
|
-
removePair(existing);
|
|
1100
|
+
removePairsForTarget(target);
|
|
1064
1101
|
}
|
|
1065
1102
|
selectedPairs.push(pair);
|
|
1066
1103
|
}
|
|
1104
|
+
clearSelection();
|
|
1067
1105
|
syncPressed();
|
|
1068
1106
|
renderPairs();
|
|
1069
1107
|
commit();
|
|
1070
1108
|
};
|
|
1109
|
+
const addSelectedPair = () => {
|
|
1110
|
+
if (!selectedSource || !selectedTarget)
|
|
1111
|
+
return;
|
|
1112
|
+
togglePair(selectedSource, selectedTarget);
|
|
1113
|
+
};
|
|
1114
|
+
const addPair = (sourceIdentifier, targetIdentifier) => {
|
|
1115
|
+
const source = sources.find((choice) => choice.identifier === sourceIdentifier);
|
|
1116
|
+
const target = targets.find((choice) => choice.identifier === targetIdentifier);
|
|
1117
|
+
if (!source || !target)
|
|
1118
|
+
return;
|
|
1119
|
+
togglePair(source, target);
|
|
1120
|
+
};
|
|
1071
1121
|
for (const source of sources) {
|
|
1072
|
-
const
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1122
|
+
const button = tokenButton(source);
|
|
1123
|
+
button.classList.add("qti3-match-source");
|
|
1124
|
+
button.draggable = true;
|
|
1125
|
+
button.addEventListener("dragstart", (event) => {
|
|
1126
|
+
draggedSource = source.identifier;
|
|
1127
|
+
event.dataTransfer?.setData("text/plain", source.identifier);
|
|
1128
|
+
event.dataTransfer?.setDragImage(button, 8, 8);
|
|
1129
|
+
});
|
|
1130
|
+
button.addEventListener("dragend", () => {
|
|
1131
|
+
draggedSource = undefined;
|
|
1132
|
+
syncPressed();
|
|
1133
|
+
});
|
|
1134
|
+
button.addEventListener("click", () => {
|
|
1135
|
+
selectedSource = source;
|
|
1136
|
+
syncPressed();
|
|
1137
|
+
addSelectedPair();
|
|
1138
|
+
});
|
|
1139
|
+
button.addEventListener("keydown", (event) => {
|
|
1140
|
+
if (event.key !== "Delete" && event.key !== "Backspace")
|
|
1141
|
+
return;
|
|
1142
|
+
event.preventDefault();
|
|
1143
|
+
removePairsForSource(source);
|
|
1144
|
+
clearSelection();
|
|
1145
|
+
syncPressed();
|
|
1146
|
+
renderPairs();
|
|
1147
|
+
commit();
|
|
1148
|
+
});
|
|
1149
|
+
sourceRegion.append(button);
|
|
1092
1150
|
}
|
|
1151
|
+
for (const target of targets) {
|
|
1152
|
+
const button = tokenButton(target);
|
|
1153
|
+
button.classList.add("qti3-match-target");
|
|
1154
|
+
button.addEventListener("dragover", (event) => {
|
|
1155
|
+
event.preventDefault();
|
|
1156
|
+
button.classList.add("qti3-drop-target");
|
|
1157
|
+
});
|
|
1158
|
+
button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
|
|
1159
|
+
button.addEventListener("drop", (event) => {
|
|
1160
|
+
event.preventDefault();
|
|
1161
|
+
button.classList.remove("qti3-drop-target");
|
|
1162
|
+
addPair(event.dataTransfer?.getData("text/plain") || draggedSource, target.identifier);
|
|
1163
|
+
});
|
|
1164
|
+
button.addEventListener("click", () => {
|
|
1165
|
+
selectedTarget = target;
|
|
1166
|
+
syncPressed();
|
|
1167
|
+
addSelectedPair();
|
|
1168
|
+
});
|
|
1169
|
+
button.addEventListener("keydown", (event) => {
|
|
1170
|
+
if (event.key !== "Delete" && event.key !== "Backspace")
|
|
1171
|
+
return;
|
|
1172
|
+
event.preventDefault();
|
|
1173
|
+
removePairsForTarget(target);
|
|
1174
|
+
clearSelection();
|
|
1175
|
+
syncPressed();
|
|
1176
|
+
renderPairs();
|
|
1177
|
+
commit();
|
|
1178
|
+
});
|
|
1179
|
+
targetRegion.append(button);
|
|
1180
|
+
}
|
|
1181
|
+
selector.append(sourceRegion, targetRegion);
|
|
1093
1182
|
syncPressed();
|
|
1094
1183
|
renderPairs();
|
|
1095
|
-
group.append(
|
|
1184
|
+
group.append(selector, pairList);
|
|
1096
1185
|
return group;
|
|
1097
1186
|
}
|
|
1098
1187
|
function pairRegionLabels(interaction) {
|
|
@@ -1103,10 +1192,7 @@ function pairRegionLabels(interaction) {
|
|
|
1103
1192
|
return { source: "Source", target: "Target" };
|
|
1104
1193
|
}
|
|
1105
1194
|
function renderGraphicOrderResponse(interaction, update, currentValue) {
|
|
1106
|
-
const group =
|
|
1107
|
-
const legend = document.createElement("legend");
|
|
1108
|
-
legend.textContent = readableType(interaction.type);
|
|
1109
|
-
group.append(legend);
|
|
1195
|
+
const group = responseGroup();
|
|
1110
1196
|
const width = objectWidth(interaction);
|
|
1111
1197
|
const height = objectHeight(interaction);
|
|
1112
1198
|
const choices = choicesOrFallback(interaction).filter((choice) => choice.role === "hotspot");
|
|
@@ -1239,12 +1325,14 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
|
|
|
1239
1325
|
list.replaceChildren(...currentChoices.map((choice, index) => {
|
|
1240
1326
|
const item = document.createElement("li");
|
|
1241
1327
|
item.className = "qti3-graphic-order-item";
|
|
1328
|
+
item.dataset.choiceIdentifier = choice.identifier;
|
|
1329
|
+
const choiceLabel = hotspotDisplayLabel(choice, choices);
|
|
1242
1330
|
const label = document.createElement("button");
|
|
1243
1331
|
label.type = "button";
|
|
1244
1332
|
label.className = "qti3-token";
|
|
1245
1333
|
label.dataset.choiceIdentifier = choice.identifier;
|
|
1246
|
-
label.textContent = `${index + 1}. ${
|
|
1247
|
-
label.setAttribute("aria-label", `${
|
|
1334
|
+
label.textContent = `${index + 1}. ${choiceLabel}`;
|
|
1335
|
+
label.setAttribute("aria-label", `${choiceLabel}, position ${index + 1} of ${currentChoices.length}`);
|
|
1248
1336
|
label.addEventListener("click", () => focusHotspot(choice.identifier));
|
|
1249
1337
|
label.addEventListener("keydown", (event) => {
|
|
1250
1338
|
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
|
@@ -1260,22 +1348,14 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
|
|
|
1260
1348
|
removeHotspot(choice.identifier);
|
|
1261
1349
|
}
|
|
1262
1350
|
});
|
|
1263
|
-
const up =
|
|
1264
|
-
up.type = "button";
|
|
1265
|
-
up.textContent = "Up";
|
|
1351
|
+
const up = movementButton("up", movementLabel(choiceLabel, "up"), () => moveHotspot(choice.identifier, -1));
|
|
1266
1352
|
up.disabled = index === 0;
|
|
1267
|
-
|
|
1268
|
-
up.addEventListener("click", () => moveHotspot(choice.identifier, -1));
|
|
1269
|
-
const down = document.createElement("button");
|
|
1270
|
-
down.type = "button";
|
|
1271
|
-
down.textContent = "Down";
|
|
1353
|
+
const down = movementButton("down", movementLabel(choiceLabel, "down"), () => moveHotspot(choice.identifier, 1));
|
|
1272
1354
|
down.disabled = index === currentChoices.length - 1;
|
|
1273
|
-
down.setAttribute("aria-label", `Move ${hotspotDisplayLabel(choice, choices)} down`);
|
|
1274
|
-
down.addEventListener("click", () => moveHotspot(choice.identifier, 1));
|
|
1275
1355
|
const remove = document.createElement("button");
|
|
1276
1356
|
remove.type = "button";
|
|
1277
1357
|
remove.textContent = "Remove";
|
|
1278
|
-
remove.setAttribute("aria-label", `Remove ${
|
|
1358
|
+
remove.setAttribute("aria-label", `Remove ${choiceLabel}`);
|
|
1279
1359
|
remove.addEventListener("click", () => removeHotspot(choice.identifier));
|
|
1280
1360
|
item.append(label, up, down, remove);
|
|
1281
1361
|
return item;
|
|
@@ -1320,10 +1400,7 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
|
|
|
1320
1400
|
return group;
|
|
1321
1401
|
}
|
|
1322
1402
|
function renderGraphicAssociateResponse(interaction, update, currentValue) {
|
|
1323
|
-
const group =
|
|
1324
|
-
const legend = document.createElement("legend");
|
|
1325
|
-
legend.textContent = readableType(interaction.type);
|
|
1326
|
-
group.append(legend);
|
|
1403
|
+
const group = responseGroup();
|
|
1327
1404
|
const width = objectWidth(interaction);
|
|
1328
1405
|
const height = objectHeight(interaction);
|
|
1329
1406
|
const choices = choicesOrFallback(interaction).filter((choice) => choice.role === "hotspot");
|
|
@@ -1515,10 +1592,7 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
|
|
|
1515
1592
|
return group;
|
|
1516
1593
|
}
|
|
1517
1594
|
function renderGapMatchResponse(interaction, update, currentValue) {
|
|
1518
|
-
const group =
|
|
1519
|
-
const legend = document.createElement("legend");
|
|
1520
|
-
legend.textContent = readableType(interaction.type);
|
|
1521
|
-
group.append(legend);
|
|
1595
|
+
const group = responseGroup();
|
|
1522
1596
|
appendGraphicContext(group, interaction);
|
|
1523
1597
|
const sources = sourceChoices(interaction);
|
|
1524
1598
|
const gaps = targetChoices(interaction);
|
|
@@ -1573,20 +1647,20 @@ function renderGapMatchResponse(interaction, update, currentValue) {
|
|
|
1573
1647
|
const button = document.createElement("button");
|
|
1574
1648
|
button.type = "button";
|
|
1575
1649
|
button.className = "qti3-gap-button";
|
|
1576
|
-
button.textContent = assigned ? assigned.text : "
|
|
1650
|
+
button.textContent = assigned ? assigned.text : "";
|
|
1577
1651
|
button.setAttribute("aria-label", assigned ? `${gapLabel}, assigned ${assigned.text}` : `${gapLabel}, empty`);
|
|
1578
1652
|
button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1653
|
+
button.addEventListener("keydown", (event) => {
|
|
1654
|
+
if (event.key !== "Delete" && event.key !== "Backspace")
|
|
1655
|
+
return;
|
|
1656
|
+
if (!assignments.has(gap.identifier))
|
|
1657
|
+
return;
|
|
1658
|
+
event.preventDefault();
|
|
1585
1659
|
assignments.delete(gap.identifier);
|
|
1586
1660
|
renderGaps();
|
|
1587
1661
|
commit();
|
|
1588
1662
|
});
|
|
1589
|
-
target.append(button
|
|
1663
|
+
target.append(button);
|
|
1590
1664
|
return target;
|
|
1591
1665
|
};
|
|
1592
1666
|
const renderGaps = () => {
|
|
@@ -1635,7 +1709,7 @@ function renderSelect(interaction, update, currentValue) {
|
|
|
1635
1709
|
const [selected] = valueToStrings(currentValue);
|
|
1636
1710
|
if (selected)
|
|
1637
1711
|
select.value = selected;
|
|
1638
|
-
select.addEventListener("change", () => update(select.value));
|
|
1712
|
+
select.addEventListener("change", () => update(select.value === "" ? null : select.value));
|
|
1639
1713
|
return select;
|
|
1640
1714
|
}
|
|
1641
1715
|
function appendInlineControl(content, control, nextSegment) {
|
|
@@ -1737,7 +1811,7 @@ function renderSliderResponse(interaction, update, currentValue) {
|
|
|
1737
1811
|
const sync = () => {
|
|
1738
1812
|
output.value = input.value;
|
|
1739
1813
|
output.textContent = input.value;
|
|
1740
|
-
update(input.value);
|
|
1814
|
+
update(coerceResponseInputValue(input.value, interaction.responseBaseType));
|
|
1741
1815
|
};
|
|
1742
1816
|
input.addEventListener("input", sync);
|
|
1743
1817
|
group.append(input, output);
|
|
@@ -1905,25 +1979,20 @@ function renderSelectPointResponse(interaction, update, currentValue) {
|
|
|
1905
1979
|
syncMarker();
|
|
1906
1980
|
const controls = document.createElement("div");
|
|
1907
1981
|
controls.className = "qti3-point-controls";
|
|
1908
|
-
for (const [
|
|
1909
|
-
["
|
|
1910
|
-
["
|
|
1911
|
-
["
|
|
1912
|
-
["
|
|
1982
|
+
for (const [direction, dx, dy] of [
|
|
1983
|
+
["up", 0, -1],
|
|
1984
|
+
["left", -1, 0],
|
|
1985
|
+
["right", 1, 0],
|
|
1986
|
+
["down", 0, 1],
|
|
1913
1987
|
]) {
|
|
1914
|
-
|
|
1915
|
-
button.type = "button";
|
|
1916
|
-
button.textContent = label;
|
|
1917
|
-
button.setAttribute("aria-label", `Move point ${label.toLowerCase()}`);
|
|
1918
|
-
button.addEventListener("click", () => {
|
|
1988
|
+
controls.append(movementButton(direction, movementLabel("point", direction), () => {
|
|
1919
1989
|
const point = mutableActivePoint();
|
|
1920
1990
|
point.x += dx;
|
|
1921
1991
|
point.y += dy;
|
|
1922
1992
|
clampPoint(point);
|
|
1923
1993
|
syncMarker();
|
|
1924
1994
|
commit();
|
|
1925
|
-
});
|
|
1926
|
-
controls.append(button);
|
|
1995
|
+
}));
|
|
1927
1996
|
}
|
|
1928
1997
|
if (isMultiple) {
|
|
1929
1998
|
const clear = document.createElement("button");
|
|
@@ -2097,18 +2166,13 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
|
|
|
2097
2166
|
marker.addEventListener("keydown", handleKey);
|
|
2098
2167
|
const controls = document.createElement("div");
|
|
2099
2168
|
controls.className = "qti3-point-controls";
|
|
2100
|
-
for (const [
|
|
2101
|
-
["
|
|
2102
|
-
["
|
|
2103
|
-
["
|
|
2104
|
-
["
|
|
2169
|
+
for (const [direction, dx, dy] of [
|
|
2170
|
+
["up", 0, -1],
|
|
2171
|
+
["left", -1, 0],
|
|
2172
|
+
["right", 1, 0],
|
|
2173
|
+
["down", 0, 1],
|
|
2105
2174
|
]) {
|
|
2106
|
-
|
|
2107
|
-
button.type = "button";
|
|
2108
|
-
button.textContent = label;
|
|
2109
|
-
button.setAttribute("aria-label", `Move object ${label.toLowerCase()}`);
|
|
2110
|
-
button.addEventListener("click", () => moveBy(dx, dy));
|
|
2111
|
-
controls.append(button);
|
|
2175
|
+
controls.append(movementButton(direction, movementLabel("object", direction), () => moveBy(dx, dy)));
|
|
2112
2176
|
}
|
|
2113
2177
|
syncMarker();
|
|
2114
2178
|
group.append(stage, coordinate, controls);
|
|
@@ -2133,8 +2197,17 @@ function renderDrawingResponse(interaction, update, currentValue) {
|
|
|
2133
2197
|
surface.style.border = "1px solid CanvasText";
|
|
2134
2198
|
surface.style.background = "Canvas";
|
|
2135
2199
|
surface.style.touchAction = "none";
|
|
2136
|
-
const
|
|
2200
|
+
const restoredStrokes = parseDrawingValue(currentValue);
|
|
2201
|
+
const authoredBackgroundHref = drawingBackgroundHref(interaction);
|
|
2202
|
+
let resolvedAuthoredBackgroundHref = authoredBackgroundHref;
|
|
2203
|
+
let activeBackgroundIsAuthored = restoredStrokes.length > 0 || !drawingResponseImage(currentValue);
|
|
2204
|
+
let activeBackgroundHref = restoredStrokes.length === 0
|
|
2205
|
+
? (drawingResponseImage(currentValue) ?? authoredBackgroundHref)
|
|
2206
|
+
: authoredBackgroundHref;
|
|
2137
2207
|
const resetSurface = () => {
|
|
2208
|
+
const background = activeBackgroundHref
|
|
2209
|
+
? drawingImageElement(activeBackgroundHref, width, height)
|
|
2210
|
+
: undefined;
|
|
2138
2211
|
surface.replaceChildren(...(background ? [background] : []));
|
|
2139
2212
|
};
|
|
2140
2213
|
resetSurface();
|
|
@@ -2142,22 +2215,35 @@ function renderDrawingResponse(interaction, update, currentValue) {
|
|
|
2142
2215
|
summary.className = "qti3-coordinate-output";
|
|
2143
2216
|
const strokes = [];
|
|
2144
2217
|
let activeStroke;
|
|
2145
|
-
|
|
2146
|
-
return points.map((point) => `${point.x} ${point.y}`).join(" ");
|
|
2147
|
-
};
|
|
2218
|
+
let commitVersion = 0;
|
|
2148
2219
|
const commit = (emitResponse = true) => {
|
|
2149
|
-
const
|
|
2150
|
-
if (emitResponse)
|
|
2151
|
-
|
|
2220
|
+
const version = ++commitVersion;
|
|
2221
|
+
if (emitResponse) {
|
|
2222
|
+
if (strokes.length === 0) {
|
|
2223
|
+
update(null);
|
|
2224
|
+
}
|
|
2225
|
+
else {
|
|
2226
|
+
void exportDrawingResponse(interaction, width, height, strokes, () => {
|
|
2227
|
+
const currentHref = currentDrawingBackgroundHref(surface);
|
|
2228
|
+
if (activeBackgroundIsAuthored && currentHref) {
|
|
2229
|
+
resolvedAuthoredBackgroundHref = currentHref;
|
|
2230
|
+
}
|
|
2231
|
+
return currentHref ?? activeBackgroundHref;
|
|
2232
|
+
}).then((value) => {
|
|
2233
|
+
if (version === commitVersion)
|
|
2234
|
+
update(value);
|
|
2235
|
+
});
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2152
2238
|
const count = strokes.length;
|
|
2153
|
-
summary.value =
|
|
2239
|
+
summary.value = serializeDrawingStrokes(strokes);
|
|
2154
2240
|
summary.textContent =
|
|
2155
2241
|
count === 0 ? "No drawing strokes." : `${count} drawing stroke${count === 1 ? "" : "s"}.`;
|
|
2156
2242
|
surface.setAttribute("aria-label", count === 0
|
|
2157
2243
|
? "Drawing response surface, no strokes"
|
|
2158
2244
|
: `Drawing response surface, ${count} stroke${count === 1 ? "" : "s"}`);
|
|
2159
2245
|
};
|
|
2160
|
-
for (const points of
|
|
2246
|
+
for (const points of restoredStrokes) {
|
|
2161
2247
|
const element = polylineElement(points);
|
|
2162
2248
|
strokes.push({ points, element });
|
|
2163
2249
|
surface.append(element);
|
|
@@ -2215,6 +2301,12 @@ function renderDrawingResponse(interaction, update, currentValue) {
|
|
|
2215
2301
|
clear.addEventListener("click", () => {
|
|
2216
2302
|
strokes.splice(0, strokes.length);
|
|
2217
2303
|
activeStroke = undefined;
|
|
2304
|
+
if (activeBackgroundIsAuthored) {
|
|
2305
|
+
resolvedAuthoredBackgroundHref =
|
|
2306
|
+
currentDrawingBackgroundHref(surface) ?? resolvedAuthoredBackgroundHref;
|
|
2307
|
+
}
|
|
2308
|
+
activeBackgroundHref = resolvedAuthoredBackgroundHref;
|
|
2309
|
+
activeBackgroundIsAuthored = true;
|
|
2218
2310
|
resetSurface();
|
|
2219
2311
|
commit();
|
|
2220
2312
|
});
|
|
@@ -2265,10 +2357,7 @@ function renderPortableCustomResponse(interaction, update, currentValue) {
|
|
|
2265
2357
|
return group;
|
|
2266
2358
|
}
|
|
2267
2359
|
function renderHotspotResponse(interaction, update, currentValue) {
|
|
2268
|
-
const group =
|
|
2269
|
-
const legend = document.createElement("legend");
|
|
2270
|
-
legend.textContent = `${readableType(interaction.type)} regions`;
|
|
2271
|
-
group.append(legend);
|
|
2360
|
+
const group = responseGroup();
|
|
2272
2361
|
const surface = document.createElement("div");
|
|
2273
2362
|
surface.className = "qti3-hotspot-surface";
|
|
2274
2363
|
const width = objectWidth(interaction);
|
|
@@ -2337,27 +2426,19 @@ function renderHotspotResponse(interaction, update, currentValue) {
|
|
|
2337
2426
|
group.append(surface, selectedSummary);
|
|
2338
2427
|
return group;
|
|
2339
2428
|
}
|
|
2340
|
-
function renderObjectAsset(interaction) {
|
|
2429
|
+
function renderObjectAsset(interaction, mediaResponse = {}) {
|
|
2341
2430
|
const object = interaction.object;
|
|
2342
|
-
const type = object?.type ?? "";
|
|
2343
2431
|
const label = interaction.prompt ?? object?.text ?? "Media interaction";
|
|
2344
|
-
|
|
2432
|
+
const mediaType = object ? mediaElementType(object) : undefined;
|
|
2433
|
+
if (object && mediaType === "audio") {
|
|
2345
2434
|
const audio = document.createElement("audio");
|
|
2346
|
-
audio
|
|
2347
|
-
audio.preload = "none";
|
|
2348
|
-
audio.src = object.data;
|
|
2349
|
-
audio.setAttribute("aria-label", label);
|
|
2350
|
-
audio.style.maxInlineSize = "100%";
|
|
2435
|
+
configureMediaElement(audio, interaction, object, label, mediaResponse);
|
|
2351
2436
|
audio.style.inlineSize = "100%";
|
|
2352
2437
|
return audio;
|
|
2353
2438
|
}
|
|
2354
|
-
if (object
|
|
2439
|
+
if (object && mediaType === "video") {
|
|
2355
2440
|
const video = document.createElement("video");
|
|
2356
|
-
video
|
|
2357
|
-
video.preload = "none";
|
|
2358
|
-
video.src = object.data;
|
|
2359
|
-
video.setAttribute("aria-label", label);
|
|
2360
|
-
video.style.maxInlineSize = "100%";
|
|
2441
|
+
configureMediaElement(video, interaction, object, label, mediaResponse);
|
|
2361
2442
|
if (object.width)
|
|
2362
2443
|
video.width = Number(object.width);
|
|
2363
2444
|
if (object.height)
|
|
@@ -2379,10 +2460,11 @@ function renderObjectAsset(interaction) {
|
|
|
2379
2460
|
const group = document.createElement("div");
|
|
2380
2461
|
group.role = "group";
|
|
2381
2462
|
group.setAttribute("aria-label", label);
|
|
2382
|
-
|
|
2463
|
+
const fallbackHref = object?.data ?? object?.sources.find((source) => source.src)?.src;
|
|
2464
|
+
if (fallbackHref) {
|
|
2383
2465
|
const link = document.createElement("a");
|
|
2384
|
-
link.href =
|
|
2385
|
-
link.textContent = object
|
|
2466
|
+
link.href = fallbackHref;
|
|
2467
|
+
link.textContent = object?.text || fallbackHref;
|
|
2386
2468
|
group.append(link);
|
|
2387
2469
|
}
|
|
2388
2470
|
else {
|
|
@@ -2390,6 +2472,113 @@ function renderObjectAsset(interaction) {
|
|
|
2390
2472
|
}
|
|
2391
2473
|
return group;
|
|
2392
2474
|
}
|
|
2475
|
+
function configureMediaElement(media, interaction, object, label, mediaResponse) {
|
|
2476
|
+
media.controls = mediaControlsMode(interaction, object) !== "none";
|
|
2477
|
+
media.preload = "none";
|
|
2478
|
+
media.autoplay = parseBooleanAttribute(interaction.attributes.autostart) ?? false;
|
|
2479
|
+
media.loop = parseBooleanAttribute(interaction.attributes.loop) ?? false;
|
|
2480
|
+
media.setAttribute("aria-label", label);
|
|
2481
|
+
media.style.maxInlineSize = "100%";
|
|
2482
|
+
copyMediaDataAttributes(media, interaction.attributes);
|
|
2483
|
+
copyMediaDataAttributes(media, object.attributes);
|
|
2484
|
+
if (object.data)
|
|
2485
|
+
media.src = object.data;
|
|
2486
|
+
for (const source of object.sources) {
|
|
2487
|
+
if (!source.src)
|
|
2488
|
+
continue;
|
|
2489
|
+
const sourceElement = document.createElement("source");
|
|
2490
|
+
sourceElement.src = source.src;
|
|
2491
|
+
if (source.type)
|
|
2492
|
+
sourceElement.type = source.type;
|
|
2493
|
+
media.append(sourceElement);
|
|
2494
|
+
}
|
|
2495
|
+
for (const track of object.tracks) {
|
|
2496
|
+
if (!track.src)
|
|
2497
|
+
continue;
|
|
2498
|
+
const trackElement = document.createElement("track");
|
|
2499
|
+
trackElement.src = track.src;
|
|
2500
|
+
if (track.kind)
|
|
2501
|
+
trackElement.kind = track.kind;
|
|
2502
|
+
if (track.srclang)
|
|
2503
|
+
trackElement.srclang = track.srclang;
|
|
2504
|
+
if (track.label)
|
|
2505
|
+
trackElement.label = track.label;
|
|
2506
|
+
if (track.default)
|
|
2507
|
+
trackElement.default = true;
|
|
2508
|
+
media.append(trackElement);
|
|
2509
|
+
}
|
|
2510
|
+
bindMediaPlayCount(media, interaction, mediaResponse);
|
|
2511
|
+
}
|
|
2512
|
+
function copyMediaDataAttributes(element, attributes) {
|
|
2513
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
2514
|
+
if (!name.startsWith("data-"))
|
|
2515
|
+
continue;
|
|
2516
|
+
element.setAttribute(name, value);
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
function mediaElementType(object) {
|
|
2520
|
+
const types = [object.type, ...object.sources.map((source) => source.type)].filter((value) => Boolean(value));
|
|
2521
|
+
if (types.some((value) => value.startsWith("audio/")))
|
|
2522
|
+
return "audio";
|
|
2523
|
+
if (types.some((value) => value.startsWith("video/")))
|
|
2524
|
+
return "video";
|
|
2525
|
+
return undefined;
|
|
2526
|
+
}
|
|
2527
|
+
function mediaControlsMode(interaction, object) {
|
|
2528
|
+
return (interaction.attributes["data-qti-media-player-controls"] ??
|
|
2529
|
+
object.attributes["data-qti-media-player-controls"]);
|
|
2530
|
+
}
|
|
2531
|
+
function bindMediaPlayCount(media, interaction, mediaResponse) {
|
|
2532
|
+
if (!mediaResponse.update)
|
|
2533
|
+
return;
|
|
2534
|
+
let playCount = mediaPlayCount(mediaResponse.currentValue ?? null);
|
|
2535
|
+
let activePlaySession = false;
|
|
2536
|
+
let readyAfterEnded = false;
|
|
2537
|
+
const maximum = maximumMediaPlays(interaction);
|
|
2538
|
+
const syncState = () => {
|
|
2539
|
+
media.dataset.playCount = String(playCount);
|
|
2540
|
+
if (maximum !== undefined && playCount >= maximum && !activePlaySession) {
|
|
2541
|
+
media.dataset.maxPlaysReached = "true";
|
|
2542
|
+
}
|
|
2543
|
+
else {
|
|
2544
|
+
delete media.dataset.maxPlaysReached;
|
|
2545
|
+
}
|
|
2546
|
+
};
|
|
2547
|
+
media.addEventListener("play", () => {
|
|
2548
|
+
if (mediaResponse.isCompleted?.()) {
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
if (!activePlaySession && maximum !== undefined && playCount >= maximum) {
|
|
2552
|
+
media.pause();
|
|
2553
|
+
syncState();
|
|
2554
|
+
return;
|
|
2555
|
+
}
|
|
2556
|
+
if (!activePlaySession && (readyAfterEnded || media.currentTime <= 0.25)) {
|
|
2557
|
+
playCount += 1;
|
|
2558
|
+
mediaResponse.update?.(playCount);
|
|
2559
|
+
activePlaySession = true;
|
|
2560
|
+
readyAfterEnded = false;
|
|
2561
|
+
syncState();
|
|
2562
|
+
return;
|
|
2563
|
+
}
|
|
2564
|
+
activePlaySession = true;
|
|
2565
|
+
readyAfterEnded = false;
|
|
2566
|
+
syncState();
|
|
2567
|
+
});
|
|
2568
|
+
media.addEventListener("ended", () => {
|
|
2569
|
+
activePlaySession = false;
|
|
2570
|
+
readyAfterEnded = true;
|
|
2571
|
+
syncState();
|
|
2572
|
+
});
|
|
2573
|
+
media.addEventListener("seeked", () => {
|
|
2574
|
+
if (!media.paused || media.currentTime > 0.25)
|
|
2575
|
+
return;
|
|
2576
|
+
activePlaySession = false;
|
|
2577
|
+
readyAfterEnded = false;
|
|
2578
|
+
syncState();
|
|
2579
|
+
});
|
|
2580
|
+
syncState();
|
|
2581
|
+
}
|
|
2393
2582
|
function objectIsImage(object) {
|
|
2394
2583
|
return Boolean(object.type?.startsWith("image/") ||
|
|
2395
2584
|
object.data?.startsWith("data:image/") ||
|
|
@@ -2398,7 +2587,7 @@ function objectIsImage(object) {
|
|
|
2398
2587
|
function appendOptions(select, choices) {
|
|
2399
2588
|
const empty = document.createElement("option");
|
|
2400
2589
|
empty.value = "";
|
|
2401
|
-
empty.textContent = "
|
|
2590
|
+
empty.textContent = "";
|
|
2402
2591
|
select.append(empty);
|
|
2403
2592
|
for (const choice of choices) {
|
|
2404
2593
|
const option = document.createElement("option");
|
|
@@ -2480,6 +2669,19 @@ function scalarString(value) {
|
|
|
2480
2669
|
return "";
|
|
2481
2670
|
return String(value);
|
|
2482
2671
|
}
|
|
2672
|
+
function coerceResponseInputValue(value, baseType) {
|
|
2673
|
+
if (baseType === "integer")
|
|
2674
|
+
return Number.parseInt(value, 10);
|
|
2675
|
+
if (baseType === "float")
|
|
2676
|
+
return Number.parseFloat(value);
|
|
2677
|
+
if (baseType === "boolean") {
|
|
2678
|
+
if (value === "true")
|
|
2679
|
+
return true;
|
|
2680
|
+
if (value === "false")
|
|
2681
|
+
return false;
|
|
2682
|
+
}
|
|
2683
|
+
return value;
|
|
2684
|
+
}
|
|
2483
2685
|
function orderChoicesFromValue(choices, value) {
|
|
2484
2686
|
const identifiers = valueToStrings(value);
|
|
2485
2687
|
if (identifiers.length === 0)
|
|
@@ -2521,6 +2723,42 @@ function parseDrawingValue(value) {
|
|
|
2521
2723
|
const raw = scalarString(value);
|
|
2522
2724
|
if (!raw)
|
|
2523
2725
|
return [];
|
|
2726
|
+
const metadata = drawingMetadataFromSvgDataUrl(raw);
|
|
2727
|
+
if (metadata)
|
|
2728
|
+
return parseDrawingStrokePayload(metadata);
|
|
2729
|
+
return parseDrawingStrokePayload(raw);
|
|
2730
|
+
}
|
|
2731
|
+
function drawingMetadataFromSvgDataUrl(raw) {
|
|
2732
|
+
if (!raw.startsWith("data:image/svg+xml"))
|
|
2733
|
+
return undefined;
|
|
2734
|
+
const commaIndex = raw.indexOf(",");
|
|
2735
|
+
if (commaIndex === -1)
|
|
2736
|
+
return undefined;
|
|
2737
|
+
const encoded = raw.slice(commaIndex + 1);
|
|
2738
|
+
let svg = "";
|
|
2739
|
+
try {
|
|
2740
|
+
svg = raw.slice(0, commaIndex).includes(";base64")
|
|
2741
|
+
? atob(encoded)
|
|
2742
|
+
: decodeURIComponent(encoded);
|
|
2743
|
+
}
|
|
2744
|
+
catch {
|
|
2745
|
+
return undefined;
|
|
2746
|
+
}
|
|
2747
|
+
const match = svg.match(/\sdata-qti3-strokes="([^"]*)"/);
|
|
2748
|
+
if (!match?.[1])
|
|
2749
|
+
return undefined;
|
|
2750
|
+
try {
|
|
2751
|
+
return decodeURIComponent(match[1]);
|
|
2752
|
+
}
|
|
2753
|
+
catch {
|
|
2754
|
+
return undefined;
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
function drawingResponseImage(value) {
|
|
2758
|
+
const raw = scalarString(value);
|
|
2759
|
+
return raw?.startsWith("data:image/") ? raw : undefined;
|
|
2760
|
+
}
|
|
2761
|
+
function parseDrawingStrokePayload(raw) {
|
|
2524
2762
|
return raw
|
|
2525
2763
|
.split("|")
|
|
2526
2764
|
.map((stroke) => {
|
|
@@ -2657,20 +2895,177 @@ function svgPoint(surface, event) {
|
|
|
2657
2895
|
y: Math.max(0, Math.min(height, y)),
|
|
2658
2896
|
};
|
|
2659
2897
|
}
|
|
2660
|
-
function
|
|
2898
|
+
async function exportDrawingResponse(interaction, width, height, strokes, backgroundHref) {
|
|
2899
|
+
const href = backgroundHref();
|
|
2900
|
+
const mime = drawingResponseMime(interaction.object);
|
|
2901
|
+
if (mime === "image/svg+xml") {
|
|
2902
|
+
return svgDrawingDataUrl(interaction, width, height, strokes, await portableImageHref(href));
|
|
2903
|
+
}
|
|
2904
|
+
return rasterDrawingDataUrl(interaction, width, height, strokes, href, mime);
|
|
2905
|
+
}
|
|
2906
|
+
function drawingResponseMime(object) {
|
|
2907
|
+
const candidates = [
|
|
2908
|
+
object?.type,
|
|
2909
|
+
object?.data,
|
|
2910
|
+
...(object?.sources.map((source) => source.type ?? source.src) ?? []),
|
|
2911
|
+
];
|
|
2912
|
+
for (const candidate of candidates) {
|
|
2913
|
+
const mime = imageMime(candidate);
|
|
2914
|
+
if (mime)
|
|
2915
|
+
return mime;
|
|
2916
|
+
}
|
|
2917
|
+
return "image/svg+xml";
|
|
2918
|
+
}
|
|
2919
|
+
function imageMime(value) {
|
|
2920
|
+
if (!value)
|
|
2921
|
+
return undefined;
|
|
2922
|
+
const normalized = value.toLowerCase().split(";")[0] ?? "";
|
|
2923
|
+
if (normalized === "image/svg+xml")
|
|
2924
|
+
return "image/svg+xml";
|
|
2925
|
+
if (normalized === "image/png")
|
|
2926
|
+
return "image/png";
|
|
2927
|
+
if (normalized === "image/jpeg" || normalized === "image/jpg")
|
|
2928
|
+
return "image/jpeg";
|
|
2929
|
+
if (normalized === "image/webp")
|
|
2930
|
+
return "image/webp";
|
|
2931
|
+
const dataMime = value.match(/^data:([^;,]+)/i)?.[1]?.toLowerCase();
|
|
2932
|
+
if (dataMime)
|
|
2933
|
+
return imageMime(dataMime);
|
|
2934
|
+
if (/\.svg(?:[?#].*)?$/i.test(value))
|
|
2935
|
+
return "image/svg+xml";
|
|
2936
|
+
if (/\.png(?:[?#].*)?$/i.test(value))
|
|
2937
|
+
return "image/png";
|
|
2938
|
+
if (/\.jpe?g(?:[?#].*)?$/i.test(value))
|
|
2939
|
+
return "image/jpeg";
|
|
2940
|
+
if (/\.webp(?:[?#].*)?$/i.test(value))
|
|
2941
|
+
return "image/webp";
|
|
2942
|
+
return undefined;
|
|
2943
|
+
}
|
|
2944
|
+
function svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref) {
|
|
2945
|
+
const markup = svgDrawingMarkup(interaction, width, height, strokes, backgroundHref);
|
|
2946
|
+
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
|
|
2947
|
+
}
|
|
2948
|
+
function svgDrawingMarkup(interaction, width, height, strokes, backgroundHref) {
|
|
2949
|
+
const strokePayload = serializeDrawingStrokes(strokes);
|
|
2950
|
+
const background = backgroundHref && interaction.object && objectIsImage(interaction.object)
|
|
2951
|
+
? `<image href="${xmlAttribute(backgroundHref)}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/>`
|
|
2952
|
+
: "";
|
|
2953
|
+
const lines = strokes
|
|
2954
|
+
.map((stroke) => {
|
|
2955
|
+
return `<polyline points="${xmlAttribute(serializeSvgPoints(stroke.points))}" fill="none" stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>`;
|
|
2956
|
+
})
|
|
2957
|
+
.join("");
|
|
2958
|
+
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>`;
|
|
2959
|
+
}
|
|
2960
|
+
async function rasterDrawingDataUrl(interaction, width, height, strokes, backgroundHref, mime) {
|
|
2961
|
+
const canvas = document.createElement("canvas");
|
|
2962
|
+
canvas.width = width;
|
|
2963
|
+
canvas.height = height;
|
|
2964
|
+
const context = canvas.getContext("2d");
|
|
2965
|
+
if (!context)
|
|
2966
|
+
return svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref);
|
|
2967
|
+
if (mime === "image/jpeg") {
|
|
2968
|
+
context.fillStyle = "#fff";
|
|
2969
|
+
context.fillRect(0, 0, width, height);
|
|
2970
|
+
}
|
|
2971
|
+
if (backgroundHref && interaction.object && objectIsImage(interaction.object)) {
|
|
2972
|
+
try {
|
|
2973
|
+
const image = await loadCanvasImage(backgroundHref);
|
|
2974
|
+
context.drawImage(image, 0, 0, width, height);
|
|
2975
|
+
}
|
|
2976
|
+
catch {
|
|
2977
|
+
// Export the candidate marks even when the authored background cannot be rasterized.
|
|
2978
|
+
}
|
|
2979
|
+
}
|
|
2980
|
+
context.strokeStyle = "#000";
|
|
2981
|
+
context.lineWidth = 3;
|
|
2982
|
+
context.lineCap = "round";
|
|
2983
|
+
context.lineJoin = "round";
|
|
2984
|
+
for (const stroke of strokes) {
|
|
2985
|
+
const [first, ...rest] = stroke.points;
|
|
2986
|
+
if (!first)
|
|
2987
|
+
continue;
|
|
2988
|
+
context.beginPath();
|
|
2989
|
+
context.moveTo(first.x, first.y);
|
|
2990
|
+
for (const point of rest)
|
|
2991
|
+
context.lineTo(point.x, point.y);
|
|
2992
|
+
context.stroke();
|
|
2993
|
+
}
|
|
2994
|
+
try {
|
|
2995
|
+
return canvas.toDataURL(mime);
|
|
2996
|
+
}
|
|
2997
|
+
catch {
|
|
2998
|
+
return svgDrawingDataUrl(interaction, width, height, strokes, backgroundHref);
|
|
2999
|
+
}
|
|
3000
|
+
}
|
|
3001
|
+
function loadCanvasImage(src) {
|
|
3002
|
+
return new Promise((resolve, reject) => {
|
|
3003
|
+
const image = new Image();
|
|
3004
|
+
image.addEventListener("load", () => resolve(image), { once: true });
|
|
3005
|
+
image.addEventListener("error", () => reject(new Error(`Unable to load ${src}`)), {
|
|
3006
|
+
once: true,
|
|
3007
|
+
});
|
|
3008
|
+
image.src = src;
|
|
3009
|
+
});
|
|
3010
|
+
}
|
|
3011
|
+
async function portableImageHref(href) {
|
|
3012
|
+
if (!href || href.startsWith("data:"))
|
|
3013
|
+
return href;
|
|
3014
|
+
try {
|
|
3015
|
+
const response = await fetch(href);
|
|
3016
|
+
if (!response.ok)
|
|
3017
|
+
return href;
|
|
3018
|
+
return await blobToDataUrl(await response.blob());
|
|
3019
|
+
}
|
|
3020
|
+
catch {
|
|
3021
|
+
return href;
|
|
3022
|
+
}
|
|
3023
|
+
}
|
|
3024
|
+
function blobToDataUrl(blob) {
|
|
3025
|
+
return new Promise((resolve, reject) => {
|
|
3026
|
+
const reader = new FileReader();
|
|
3027
|
+
reader.addEventListener("load", () => {
|
|
3028
|
+
resolve(String(reader.result ?? ""));
|
|
3029
|
+
});
|
|
3030
|
+
reader.addEventListener("error", () => {
|
|
3031
|
+
reject(reader.error ?? new Error("Unable to read drawing background."));
|
|
3032
|
+
});
|
|
3033
|
+
reader.readAsDataURL(blob);
|
|
3034
|
+
});
|
|
3035
|
+
}
|
|
3036
|
+
function drawingBackgroundHref(interaction) {
|
|
2661
3037
|
if (!interaction.object?.data || !objectIsImage(interaction.object))
|
|
2662
3038
|
return undefined;
|
|
3039
|
+
return interaction.object.data;
|
|
3040
|
+
}
|
|
3041
|
+
function drawingImageElement(href, width, height) {
|
|
2663
3042
|
const image = document.createElementNS("http://www.w3.org/2000/svg", "image");
|
|
2664
|
-
image.setAttribute("href",
|
|
3043
|
+
image.setAttribute("href", href);
|
|
2665
3044
|
image.setAttribute("width", String(width));
|
|
2666
3045
|
image.setAttribute("height", String(height));
|
|
2667
3046
|
image.setAttribute("preserveAspectRatio", "xMidYMid meet");
|
|
2668
3047
|
image.setAttribute("aria-hidden", "true");
|
|
2669
3048
|
return image;
|
|
2670
3049
|
}
|
|
3050
|
+
function currentDrawingBackgroundHref(surface) {
|
|
3051
|
+
return surface.querySelector("image")?.getAttribute("href") ?? undefined;
|
|
3052
|
+
}
|
|
2671
3053
|
function serializeSvgPoints(points) {
|
|
2672
3054
|
return points.map((point) => `${point.x},${point.y}`).join(" ");
|
|
2673
3055
|
}
|
|
3056
|
+
function serializeDrawingStrokes(strokes) {
|
|
3057
|
+
return strokes.map((stroke) => serializeDrawingStroke(stroke.points)).join(" | ");
|
|
3058
|
+
}
|
|
3059
|
+
function serializeDrawingStroke(points) {
|
|
3060
|
+
return points.map((point) => `${point.x} ${point.y}`).join(" ");
|
|
3061
|
+
}
|
|
3062
|
+
function xmlAttribute(value) {
|
|
3063
|
+
return value
|
|
3064
|
+
.replaceAll("&", "&")
|
|
3065
|
+
.replaceAll('"', """)
|
|
3066
|
+
.replaceAll("<", "<")
|
|
3067
|
+
.replaceAll(">", ">");
|
|
3068
|
+
}
|
|
2674
3069
|
function polylineElement(points) {
|
|
2675
3070
|
const line = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
|
|
2676
3071
|
line.setAttribute("points", serializeSvgPoints(points));
|
|
@@ -2898,11 +3293,6 @@ function readableType(type) {
|
|
|
2898
3293
|
.replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`)
|
|
2899
3294
|
.replace(/^./, (letter) => letter.toUpperCase());
|
|
2900
3295
|
}
|
|
2901
|
-
function orderedResponseLegend(type) {
|
|
2902
|
-
if (type === "order")
|
|
2903
|
-
return readableType(type);
|
|
2904
|
-
return `${readableType(type)} order`;
|
|
2905
|
-
}
|
|
2906
3296
|
function errorView(message) {
|
|
2907
3297
|
const element = document.createElement("p");
|
|
2908
3298
|
element.role = "alert";
|
|
@@ -2938,12 +3328,24 @@ function playerStyleElement() {
|
|
|
2938
3328
|
const style = document.createElement("style");
|
|
2939
3329
|
style.textContent = `
|
|
2940
3330
|
.qti3-player {
|
|
3331
|
+
--qti3-match-accent: #2f6fca;
|
|
3332
|
+
--qti3-match-target-bg: #f5f6f7;
|
|
3333
|
+
--qti3-match-target-border: #6f7782;
|
|
3334
|
+
|
|
2941
3335
|
display: grid;
|
|
2942
3336
|
gap: 1rem;
|
|
2943
3337
|
max-inline-size: 72rem;
|
|
2944
3338
|
font: 16px/1.45 system-ui, sans-serif;
|
|
2945
3339
|
}
|
|
2946
3340
|
|
|
3341
|
+
@supports (color: light-dark(#000, #fff)) {
|
|
3342
|
+
.qti3-player {
|
|
3343
|
+
--qti3-match-accent: light-dark(#2f6fca, #8ab4f8);
|
|
3344
|
+
--qti3-match-target-bg: light-dark(#f5f6f7, #202124);
|
|
3345
|
+
--qti3-match-target-border: light-dark(#6f7782, #9aa0a6);
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
|
|
2947
3349
|
.qti3-interaction {
|
|
2948
3350
|
display: grid;
|
|
2949
3351
|
gap: 0.75rem;
|
|
@@ -2984,11 +3386,14 @@ function playerStyleElement() {
|
|
|
2984
3386
|
color: CanvasText;
|
|
2985
3387
|
}
|
|
2986
3388
|
|
|
2987
|
-
.qti3-
|
|
3389
|
+
.qti3-response-group {
|
|
2988
3390
|
min-inline-size: 0;
|
|
2989
3391
|
}
|
|
2990
3392
|
|
|
2991
|
-
.qti3-
|
|
3393
|
+
.qti3-response-group > * + * {
|
|
3394
|
+
margin-block-start: 0.75rem;
|
|
3395
|
+
}
|
|
3396
|
+
|
|
2992
3397
|
.qti3-reorder-item,
|
|
2993
3398
|
.qti3-token-region,
|
|
2994
3399
|
.qti3-pair-chip,
|
|
@@ -3013,52 +3418,50 @@ function playerStyleElement() {
|
|
|
3013
3418
|
align-items: start;
|
|
3014
3419
|
}
|
|
3015
3420
|
|
|
3016
|
-
.qti3-match-
|
|
3421
|
+
.qti3-match-selector {
|
|
3017
3422
|
display: grid;
|
|
3018
|
-
gap:
|
|
3423
|
+
gap: 1.5rem;
|
|
3019
3424
|
inline-size: 100%;
|
|
3020
3425
|
max-inline-size: 72rem;
|
|
3021
3426
|
box-sizing: border-box;
|
|
3022
3427
|
}
|
|
3023
3428
|
|
|
3024
|
-
.qti3-match-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
gap: 0.75rem;
|
|
3028
|
-
align-items: start;
|
|
3029
|
-
inline-size: 100%;
|
|
3030
|
-
min-inline-size: 0;
|
|
3031
|
-
box-sizing: border-box;
|
|
3032
|
-
padding-block: 0.5rem;
|
|
3033
|
-
border-block-end: 1px solid CanvasText;
|
|
3034
|
-
}
|
|
3035
|
-
|
|
3036
|
-
.qti3-match-source {
|
|
3037
|
-
font-weight: 700;
|
|
3429
|
+
.qti3-match-source-bank,
|
|
3430
|
+
.qti3-match-target-bank {
|
|
3431
|
+
align-items: stretch;
|
|
3038
3432
|
}
|
|
3039
3433
|
|
|
3040
|
-
.qti3-match-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
inline-size: 100%;
|
|
3045
|
-
min-inline-size: 0;
|
|
3046
|
-
box-sizing: border-box;
|
|
3434
|
+
.qti3-token.qti3-match-source {
|
|
3435
|
+
border-color: var(--qti3-match-accent);
|
|
3436
|
+
background: Canvas;
|
|
3437
|
+
color: var(--qti3-match-accent);
|
|
3047
3438
|
}
|
|
3048
3439
|
|
|
3049
|
-
.qti3-match-target {
|
|
3050
|
-
flex: 1 1
|
|
3440
|
+
.qti3-token.qti3-match-target {
|
|
3441
|
+
flex: 1 1 9rem;
|
|
3051
3442
|
min-inline-size: 0;
|
|
3052
3443
|
max-inline-size: 100%;
|
|
3444
|
+
min-block-size: 5rem;
|
|
3053
3445
|
box-sizing: border-box;
|
|
3446
|
+
border-color: var(--qti3-match-target-border);
|
|
3447
|
+
background: var(--qti3-match-target-bg);
|
|
3448
|
+
color: CanvasText;
|
|
3449
|
+
font-weight: 700;
|
|
3054
3450
|
white-space: normal;
|
|
3055
3451
|
overflow-wrap: anywhere;
|
|
3056
|
-
text-align:
|
|
3452
|
+
text-align: center;
|
|
3057
3453
|
}
|
|
3058
3454
|
|
|
3059
|
-
@media (
|
|
3060
|
-
.qti3-match-
|
|
3061
|
-
|
|
3455
|
+
@media (forced-colors: active) {
|
|
3456
|
+
.qti3-token.qti3-match-source {
|
|
3457
|
+
border-color: LinkText;
|
|
3458
|
+
color: LinkText;
|
|
3459
|
+
}
|
|
3460
|
+
|
|
3461
|
+
.qti3-token.qti3-match-target {
|
|
3462
|
+
border-color: GrayText;
|
|
3463
|
+
background: ButtonFace;
|
|
3464
|
+
color: ButtonText;
|
|
3062
3465
|
}
|
|
3063
3466
|
}
|
|
3064
3467
|
|
|
@@ -3172,6 +3575,15 @@ function playerStyleElement() {
|
|
|
3172
3575
|
cursor: grab;
|
|
3173
3576
|
}
|
|
3174
3577
|
|
|
3578
|
+
.qti3-icon-button {
|
|
3579
|
+
display: inline-grid;
|
|
3580
|
+
place-items: center;
|
|
3581
|
+
inline-size: 2.25rem;
|
|
3582
|
+
block-size: 2.25rem;
|
|
3583
|
+
padding: 0;
|
|
3584
|
+
line-height: 1;
|
|
3585
|
+
}
|
|
3586
|
+
|
|
3175
3587
|
.qti3-token[aria-pressed="true"],
|
|
3176
3588
|
.qti3-pair-chip {
|
|
3177
3589
|
background: Highlight;
|
|
@@ -3207,6 +3619,8 @@ function playerStyleElement() {
|
|
|
3207
3619
|
|
|
3208
3620
|
.qti3-gap-passage .qti3-gap-target {
|
|
3209
3621
|
display: inline-flex;
|
|
3622
|
+
padding: 0;
|
|
3623
|
+
border: 0;
|
|
3210
3624
|
margin-inline: 0.15rem;
|
|
3211
3625
|
margin-block: 0.2rem;
|
|
3212
3626
|
vertical-align: middle;
|
|
@@ -3214,6 +3628,7 @@ function playerStyleElement() {
|
|
|
3214
3628
|
|
|
3215
3629
|
.qti3-gap-button {
|
|
3216
3630
|
min-inline-size: 8rem;
|
|
3631
|
+
min-block-size: 2.25rem;
|
|
3217
3632
|
text-align: start;
|
|
3218
3633
|
}
|
|
3219
3634
|
|
|
@@ -3415,6 +3830,8 @@ function responseCount(value) {
|
|
|
3415
3830
|
function maximumAllowedResponses(interaction) {
|
|
3416
3831
|
if (!interaction)
|
|
3417
3832
|
return undefined;
|
|
3833
|
+
if (interaction.type === "media")
|
|
3834
|
+
return maximumMediaPlays(interaction);
|
|
3418
3835
|
const explicit = interaction.attributes["max-choices"] ?? interaction.attributes["max-associations"];
|
|
3419
3836
|
if (explicit === undefined)
|
|
3420
3837
|
return undefined;
|
|
@@ -3426,12 +3843,33 @@ function maximumAllowedResponses(interaction) {
|
|
|
3426
3843
|
function minimumRequiredResponses(interaction) {
|
|
3427
3844
|
if (!interaction)
|
|
3428
3845
|
return 1;
|
|
3846
|
+
if (interaction.type === "media")
|
|
3847
|
+
return minimumMediaPlays(interaction);
|
|
3429
3848
|
const explicit = interaction.attributes["min-choices"] ?? interaction.attributes["min-associations"];
|
|
3430
3849
|
if (explicit === undefined)
|
|
3431
3850
|
return 1;
|
|
3432
3851
|
const parsed = Number(explicit);
|
|
3433
3852
|
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 1;
|
|
3434
3853
|
}
|
|
3854
|
+
function minimumMediaPlays(interaction) {
|
|
3855
|
+
const parsed = Number(interaction.attributes["min-plays"] ?? "0");
|
|
3856
|
+
return Number.isInteger(parsed) && parsed >= 0 ? parsed : 0;
|
|
3857
|
+
}
|
|
3858
|
+
function maximumMediaPlays(interaction) {
|
|
3859
|
+
const parsed = Number(interaction.attributes["max-plays"] ?? "0");
|
|
3860
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
|
|
3861
|
+
}
|
|
3862
|
+
function mediaPlayCount(value) {
|
|
3863
|
+
const parsed = typeof value === "number" ? value : typeof value === "string" ? Number(value) : 0;
|
|
3864
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : 0;
|
|
3865
|
+
}
|
|
3866
|
+
function parseBooleanAttribute(value) {
|
|
3867
|
+
if (value === "true" || value === "1")
|
|
3868
|
+
return true;
|
|
3869
|
+
if (value === "false" || value === "0")
|
|
3870
|
+
return false;
|
|
3871
|
+
return undefined;
|
|
3872
|
+
}
|
|
3435
3873
|
function matchMaxDiagnostics(responseIdentifier, interaction, response) {
|
|
3436
3874
|
const identifiers = responseChoiceIdentifiers(response);
|
|
3437
3875
|
if (identifiers.length === 0)
|