@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.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.dispatchEvent(new CustomEvent("qti-ready", { detail: { item: result.document.item } }));
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 validationMessages = this.sessionControl.validateResponses
50
- ? this.validateResponses()
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 = session.serialize();
56
- state.validationMessages = validationMessages;
57
- this.dispatchEvent(new CustomEvent("qti-validation", { detail: { validationMessages } }));
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.dispatchEvent(new CustomEvent("qti-score", { detail: result }));
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?.setStatus("suspended");
100
- this.dispatchEvent(new CustomEvent("qti-suspend", { detail: { state: this.serialize() } }));
101
- this.emitStateChange();
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
- this.dispatchEvent(new CustomEvent("qti-endattempt", { detail: { state: this.serialize() } }));
113
- this.emitStateChange();
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
- this.dispatchEvent(new CustomEvent("qti-statechange", { detail: { state } }));
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.dispatchEvent(new CustomEvent("qti-responsechange", {
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.dispatchEvent(new CustomEvent("qti-responsechange", {
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, .qti3-actions button")) {
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 = responseCount(state.responses[declaration.identifier] ?? null);
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
- (minimum === 1
527
- ? `${declaration.identifier} requires a response.`
528
- : `${declaration.identifier} requires at least ${minimum} responses.`),
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
- `${declaration.identifier} allows at most ${maximum} response${maximum === 1 ? "" : "s"}.`,
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 = document.createElement("fieldset");
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 = document.createElement("fieldset");
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 = document.createElement("button");
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
- up.setAttribute("aria-label", `Move ${choice.text} up`);
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 = document.createElement("fieldset");
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 = document.createElement("fieldset");
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
- const grid = document.createElement("div");
1004
- grid.className = "qti3-match-grid";
1005
- grid.role = "group";
1006
- grid.setAttribute("aria-label", "Match rows");
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
- const existingSourcePairs = selectedPairs.filter((existing) => existing.startsWith(`${source.identifier} `));
1057
- for (const existing of existingSourcePairs)
1058
- removePair(existing);
1097
+ removePairsForSource(source);
1059
1098
  }
1060
1099
  if (parseUnlimitedMaximum(target.attributes["match-max"]) === 1) {
1061
- const existingTargetPairs = selectedPairs.filter((existing) => existing.endsWith(` ${target.identifier}`));
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 row = document.createElement("div");
1073
- row.className = "qti3-match-row";
1074
- row.dataset.sourceIdentifier = source.identifier;
1075
- const sourceLabel = document.createElement("span");
1076
- sourceLabel.className = "qti3-match-source";
1077
- sourceLabel.textContent = source.text;
1078
- const targetRegion = document.createElement("div");
1079
- targetRegion.className = "qti3-match-targets";
1080
- targetRegion.role = "group";
1081
- targetRegion.setAttribute("aria-label", `Targets for ${source.text}`);
1082
- for (const target of targets) {
1083
- const button = tokenButton(target);
1084
- button.classList.add("qti3-match-target");
1085
- button.dataset.sourceIdentifier = source.identifier;
1086
- button.setAttribute("aria-label", `${source.text}: ${target.text}`);
1087
- button.addEventListener("click", () => togglePair(source, target));
1088
- targetRegion.append(button);
1089
- }
1090
- row.append(sourceLabel, targetRegion);
1091
- grid.append(row);
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(grid, pairList);
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 = document.createElement("fieldset");
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}. ${hotspotDisplayLabel(choice, choices)}`;
1247
- label.setAttribute("aria-label", `${hotspotDisplayLabel(choice, choices)}, position ${index + 1} of ${currentChoices.length}`);
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 = document.createElement("button");
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
- up.setAttribute("aria-label", `Move ${hotspotDisplayLabel(choice, choices)} up`);
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 ${hotspotDisplayLabel(choice, choices)}`);
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 = document.createElement("fieldset");
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 = document.createElement("fieldset");
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 : "Empty";
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
- const remove = document.createElement("button");
1580
- remove.type = "button";
1581
- remove.textContent = "Remove";
1582
- remove.disabled = !assigned;
1583
- remove.setAttribute("aria-label", `Remove ${gapLabel.toLowerCase()} assignment`);
1584
- remove.addEventListener("click", () => {
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, remove);
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 [label, dx, dy] of [
1909
- ["Up", 0, -1],
1910
- ["Left", -1, 0],
1911
- ["Right", 1, 0],
1912
- ["Down", 0, 1],
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
- const button = document.createElement("button");
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 [label, dx, dy] of [
2101
- ["Up", 0, -1],
2102
- ["Left", -1, 0],
2103
- ["Right", 1, 0],
2104
- ["Down", 0, 1],
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
- const button = document.createElement("button");
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 background = drawingBackgroundImage(interaction, width, height);
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
- const serializeStroke = (points) => {
2146
- return points.map((point) => `${point.x} ${point.y}`).join(" ");
2147
- };
2218
+ let commitVersion = 0;
2148
2219
  const commit = (emitResponse = true) => {
2149
- const value = strokes.map((stroke) => serializeStroke(stroke.points)).join(" | ");
2150
- if (emitResponse)
2151
- update(value);
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 = 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 parseDrawingValue(currentValue)) {
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 = document.createElement("fieldset");
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
- if (object?.data && type.startsWith("audio/")) {
2432
+ const mediaType = object ? mediaElementType(object) : undefined;
2433
+ if (object && mediaType === "audio") {
2345
2434
  const audio = document.createElement("audio");
2346
- audio.controls = true;
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?.data && type.startsWith("video/")) {
2439
+ if (object && mediaType === "video") {
2355
2440
  const video = document.createElement("video");
2356
- video.controls = true;
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
- if (object?.data) {
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 = object.data;
2385
- link.textContent = object.text || object.data;
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 = "Select";
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 drawingBackgroundImage(interaction, width, height) {
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", interaction.object.data);
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("&", "&amp;")
3065
+ .replaceAll('"', "&quot;")
3066
+ .replaceAll("<", "&lt;")
3067
+ .replaceAll(">", "&gt;");
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-player fieldset {
3389
+ .qti3-response-group {
2988
3390
  min-inline-size: 0;
2989
3391
  }
2990
3392
 
2991
- .qti3-actions,
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-grid {
3421
+ .qti3-match-selector {
3017
3422
  display: grid;
3018
- gap: 0.5rem;
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-row {
3025
- display: grid;
3026
- grid-template-columns: minmax(0, 1fr);
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-targets {
3041
- display: flex;
3042
- flex-wrap: wrap;
3043
- gap: 0.5rem;
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 12rem;
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: start;
3452
+ text-align: center;
3057
3453
  }
3058
3454
 
3059
- @media (min-width: 768px) {
3060
- .qti3-match-row {
3061
- grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr);
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)