@longsightgroup/qti3-player 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/dist/icons.d.ts +8 -0
  2. package/dist/icons.d.ts.map +1 -0
  3. package/dist/icons.js +45 -0
  4. package/dist/icons.js.map +1 -0
  5. package/dist/index.d.ts +3 -134
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -4712
  8. package/dist/index.js.map +1 -1
  9. package/dist/interaction-support.d.ts +34 -0
  10. package/dist/interaction-support.d.ts.map +1 -0
  11. package/dist/interaction-support.js +189 -0
  12. package/dist/interaction-support.js.map +1 -0
  13. package/dist/movement.d.ts +3 -0
  14. package/dist/movement.d.ts.map +1 -0
  15. package/dist/movement.js +21 -0
  16. package/dist/movement.js.map +1 -0
  17. package/dist/player-element.d.ts +60 -0
  18. package/dist/player-element.d.ts.map +1 -0
  19. package/dist/player-element.js +367 -0
  20. package/dist/player-element.js.map +1 -0
  21. package/dist/player-locale.d.ts +6 -0
  22. package/dist/player-locale.d.ts.map +1 -0
  23. package/dist/player-locale.js +205 -0
  24. package/dist/player-locale.js.map +1 -0
  25. package/dist/player-messages.d.ts +40 -0
  26. package/dist/player-messages.d.ts.map +1 -0
  27. package/dist/player-messages.js +2 -0
  28. package/dist/player-messages.js.map +1 -0
  29. package/dist/player-styles.d.ts +3 -0
  30. package/dist/player-styles.d.ts.map +1 -0
  31. package/dist/player-styles.js +24 -0
  32. package/dist/player-styles.js.map +1 -0
  33. package/dist/player-types.d.ts +71 -0
  34. package/dist/player-types.d.ts.map +1 -0
  35. package/dist/player-types.js +2 -0
  36. package/dist/player-types.js.map +1 -0
  37. package/dist/player-validation-dom.d.ts +3 -0
  38. package/dist/player-validation-dom.d.ts.map +1 -0
  39. package/dist/player-validation-dom.js +28 -0
  40. package/dist/player-validation-dom.js.map +1 -0
  41. package/dist/player-validation.d.ts +13 -0
  42. package/dist/player-validation.d.ts.map +1 -0
  43. package/dist/player-validation.js +123 -0
  44. package/dist/player-validation.js.map +1 -0
  45. package/dist/portable-custom-support.d.ts +11 -0
  46. package/dist/portable-custom-support.d.ts.map +1 -0
  47. package/dist/portable-custom-support.js +70 -0
  48. package/dist/portable-custom-support.js.map +1 -0
  49. package/dist/response-limits.d.ts +9 -0
  50. package/dist/response-limits.d.ts.map +1 -0
  51. package/dist/response-limits.js +44 -0
  52. package/dist/response-limits.js.map +1 -0
  53. package/package.json +4 -4
  54. package/src/content/content-dom.ts +274 -0
  55. package/src/content/content-renderer.ts +114 -0
  56. package/src/controls/remove-button.ts +13 -0
  57. package/src/icons.ts +47 -0
  58. package/src/index.ts +26 -5307
  59. package/src/interaction-support.ts +263 -0
  60. package/src/interactions/choice-interaction.ts +92 -0
  61. package/src/interactions/drawing-interaction.ts +447 -0
  62. package/src/interactions/end-attempt-interaction.ts +19 -0
  63. package/src/interactions/gap-match-interaction.ts +337 -0
  64. package/src/interactions/graphic-associate-interaction.ts +324 -0
  65. package/src/interactions/graphic-context.ts +33 -0
  66. package/src/interactions/hotspot-interaction.ts +87 -0
  67. package/src/interactions/hottext-interaction.ts +81 -0
  68. package/src/interactions/inline-choice-interaction.ts +45 -0
  69. package/src/interactions/inline-controls.ts +21 -0
  70. package/src/interactions/interaction-diagnostics.ts +159 -0
  71. package/src/interactions/interaction-dispatch.ts +9 -0
  72. package/src/interactions/interaction-label.ts +10 -0
  73. package/src/interactions/interaction-registry.ts +209 -0
  74. package/src/interactions/match-interaction.ts +199 -0
  75. package/src/interactions/object-asset.ts +212 -0
  76. package/src/interactions/pair-interaction.ts +147 -0
  77. package/src/interactions/point-value.ts +41 -0
  78. package/src/interactions/portable-custom-interaction.ts +139 -0
  79. package/src/interactions/position-object-interaction.ts +210 -0
  80. package/src/interactions/routing.ts +27 -0
  81. package/src/interactions/select-point-interaction.ts +185 -0
  82. package/src/interactions/shared.ts +56 -0
  83. package/src/interactions/text-interaction.ts +127 -0
  84. package/src/interactions/unsupported-interaction.ts +25 -0
  85. package/src/interactions/upload-interaction.ts +16 -0
  86. package/src/movement.ts +29 -0
  87. package/src/player/attempt-availability.ts +36 -0
  88. package/src/player/content-state.ts +63 -0
  89. package/src/player/dynamic-body.ts +40 -0
  90. package/src/player/feedback-panel.ts +23 -0
  91. package/src/player/fetch-xml.ts +8 -0
  92. package/src/player/interaction-render.ts +89 -0
  93. package/src/player/render-shell.ts +44 -0
  94. package/src/player/resolve-assets.ts +12 -0
  95. package/src/player/validation-messages.ts +42 -0
  96. package/src/player-element.ts +493 -0
  97. package/src/player-locale.ts +232 -0
  98. package/src/player-messages.ts +31 -0
  99. package/src/player-styles.ts +25 -0
  100. package/src/player-types.ts +99 -0
  101. package/src/player-validation-dom.ts +31 -0
  102. package/src/player-validation.ts +158 -0
  103. package/src/portable-custom-support.ts +74 -0
  104. package/src/reorder/a11y.ts +40 -0
  105. package/src/reorder/graphic-order-interaction.ts +260 -0
  106. package/src/reorder/list-controls.ts +114 -0
  107. package/src/reorder/order-interaction.ts +75 -0
  108. package/src/response-limits.ts +47 -0
  109. package/src/styles/base-styles.ts +117 -0
  110. package/src/styles/choice-hottext-styles.ts +75 -0
  111. package/src/styles/control-styles.ts +113 -0
  112. package/src/styles/drawing-styles.ts +29 -0
  113. package/src/styles/gap-match-styles.ts +32 -0
  114. package/src/styles/graphic-styles.ts +294 -0
  115. package/src/styles/match-pair-styles.ts +61 -0
  116. package/src/styles/text-slider-styles.ts +34 -0
@@ -0,0 +1,199 @@
1
+ import type { QtiChoice, QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import { removeButton } from "../controls/remove-button.js";
3
+ import { missingChoicesMessage, responseGroup, valueToStrings } from "../interaction-support.js";
4
+ import type { QtiPlayerMessages } from "../player-messages.js";
5
+ import { parseUnlimitedMaximum } from "../response-limits.js";
6
+ import { choiceText, sourceChoices, targetChoices, tokenButton, tokenRegion } from "./shared.js";
7
+
8
+ export function renderMatchResponse(
9
+ interaction: QtiInteraction,
10
+ update: (value: QtiValue) => void,
11
+ currentValue: QtiValue,
12
+ messages: QtiPlayerMessages,
13
+ ): HTMLElement {
14
+ const group = responseGroup();
15
+
16
+ const sources = sourceChoices(interaction);
17
+ const targets = targetChoices(interaction);
18
+ if (sources.length === 0 || targets.length === 0) {
19
+ group.append(missingChoicesMessage(interaction));
20
+ return group;
21
+ }
22
+ const selectedPairs: string[] = valueToStrings(currentValue);
23
+ let selectedSource: QtiChoice | undefined;
24
+ let selectedTarget: QtiChoice | undefined;
25
+ let draggedSource: string | undefined;
26
+
27
+ const selector = document.createElement("div");
28
+ selector.className = "qti3-match-selector";
29
+ const sourceRegion = tokenRegion("Match sources");
30
+ sourceRegion.classList.add("qti3-match-source-bank");
31
+ const targetRegion = tokenRegion("Match targets");
32
+ targetRegion.classList.add("qti3-match-target-bank");
33
+ const pairList = document.createElement("ul");
34
+ pairList.className = "qti3-pair-list";
35
+ pairList.setAttribute("aria-label", "Match selected pairs");
36
+
37
+ const commit = () => {
38
+ if (interaction.responseCardinality === "single") update(selectedPairs[0] ?? null);
39
+ else update([...selectedPairs]);
40
+ };
41
+ const removePair = (pair: string) => {
42
+ const index = selectedPairs.indexOf(pair);
43
+ if (index >= 0) selectedPairs.splice(index, 1);
44
+ };
45
+ const syncPressed = () => {
46
+ for (const button of sourceRegion.querySelectorAll<HTMLButtonElement>("button")) {
47
+ const identifier = button.dataset.choiceIdentifier ?? "";
48
+ button.setAttribute(
49
+ "aria-pressed",
50
+ identifier === selectedSource?.identifier ||
51
+ selectedPairs.some((pair) => pair.startsWith(`${identifier} `))
52
+ ? "true"
53
+ : "false",
54
+ );
55
+ }
56
+ for (const button of targetRegion.querySelectorAll<HTMLButtonElement>("button")) {
57
+ const identifier = button.dataset.choiceIdentifier ?? "";
58
+ button.setAttribute(
59
+ "aria-pressed",
60
+ identifier === selectedTarget?.identifier ||
61
+ selectedPairs.some((pair) => pair.endsWith(` ${identifier}`))
62
+ ? "true"
63
+ : "false",
64
+ );
65
+ }
66
+ };
67
+ const renderPairs = () => {
68
+ pairList.replaceChildren(
69
+ ...selectedPairs.map((pair) => {
70
+ const [source, target] = pair.split(" ");
71
+ const label = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
72
+ const item = document.createElement("li");
73
+ item.className = "qti3-pair-chip";
74
+ const text = document.createElement("span");
75
+ text.textContent = label;
76
+ const remove = removeButton(label, messages);
77
+ remove.addEventListener("click", () => {
78
+ removePair(pair);
79
+ syncPressed();
80
+ renderPairs();
81
+ commit();
82
+ });
83
+ item.append(text, remove);
84
+ return item;
85
+ }),
86
+ );
87
+ };
88
+ const clearSelection = () => {
89
+ selectedSource = undefined;
90
+ selectedTarget = undefined;
91
+ };
92
+ const removePairsForSource = (source: QtiChoice) => {
93
+ for (const existing of selectedPairs.filter((pair) =>
94
+ pair.startsWith(`${source.identifier} `),
95
+ )) {
96
+ removePair(existing);
97
+ }
98
+ };
99
+ const removePairsForTarget = (target: QtiChoice) => {
100
+ for (const existing of selectedPairs.filter((pair) => pair.endsWith(` ${target.identifier}`))) {
101
+ removePair(existing);
102
+ }
103
+ };
104
+ const togglePair = (source: QtiChoice, target: QtiChoice) => {
105
+ const pair = `${source.identifier} ${target.identifier}`;
106
+ if (selectedPairs.includes(pair)) {
107
+ removePair(pair);
108
+ } else {
109
+ if (interaction.responseCardinality === "single") selectedPairs.splice(0);
110
+ if (parseUnlimitedMaximum(source.attributes["match-max"]) === 1) {
111
+ removePairsForSource(source);
112
+ }
113
+ if (parseUnlimitedMaximum(target.attributes["match-max"]) === 1) {
114
+ removePairsForTarget(target);
115
+ }
116
+ selectedPairs.push(pair);
117
+ }
118
+ clearSelection();
119
+ syncPressed();
120
+ renderPairs();
121
+ commit();
122
+ };
123
+ const addSelectedPair = () => {
124
+ if (!selectedSource || !selectedTarget) return;
125
+ togglePair(selectedSource, selectedTarget);
126
+ };
127
+ const addPair = (sourceIdentifier: string | undefined, targetIdentifier: string): void => {
128
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
129
+ const target = targets.find((choice) => choice.identifier === targetIdentifier);
130
+ if (!source || !target) return;
131
+ togglePair(source, target);
132
+ };
133
+
134
+ for (const source of sources) {
135
+ const button = tokenButton(source);
136
+ button.classList.add("qti3-match-source");
137
+ button.draggable = true;
138
+ button.addEventListener("dragstart", (event) => {
139
+ draggedSource = source.identifier;
140
+ event.dataTransfer?.setData("text/plain", source.identifier);
141
+ event.dataTransfer?.setDragImage(button, 8, 8);
142
+ });
143
+ button.addEventListener("dragend", () => {
144
+ draggedSource = undefined;
145
+ syncPressed();
146
+ });
147
+ button.addEventListener("click", () => {
148
+ selectedSource = source;
149
+ syncPressed();
150
+ addSelectedPair();
151
+ });
152
+ button.addEventListener("keydown", (event) => {
153
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
154
+ event.preventDefault();
155
+ removePairsForSource(source);
156
+ clearSelection();
157
+ syncPressed();
158
+ renderPairs();
159
+ commit();
160
+ });
161
+ sourceRegion.append(button);
162
+ }
163
+
164
+ for (const target of targets) {
165
+ const button = tokenButton(target);
166
+ button.classList.add("qti3-match-target");
167
+ button.addEventListener("dragover", (event) => {
168
+ event.preventDefault();
169
+ button.classList.add("qti3-drop-target");
170
+ });
171
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
172
+ button.addEventListener("drop", (event) => {
173
+ event.preventDefault();
174
+ button.classList.remove("qti3-drop-target");
175
+ addPair(event.dataTransfer?.getData("text/plain") || draggedSource, target.identifier);
176
+ });
177
+ button.addEventListener("click", () => {
178
+ selectedTarget = target;
179
+ syncPressed();
180
+ addSelectedPair();
181
+ });
182
+ button.addEventListener("keydown", (event) => {
183
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
184
+ event.preventDefault();
185
+ removePairsForTarget(target);
186
+ clearSelection();
187
+ syncPressed();
188
+ renderPairs();
189
+ commit();
190
+ });
191
+ targetRegion.append(button);
192
+ }
193
+
194
+ selector.append(sourceRegion, targetRegion);
195
+ syncPressed();
196
+ renderPairs();
197
+ group.append(selector, pairList);
198
+ return group;
199
+ }
@@ -0,0 +1,212 @@
1
+ import type { QtiInteraction, QtiObjectAsset, QtiValue } from "@longsightgroup/qti3-core";
2
+ import { objectIsImage } from "../interaction-support.js";
3
+ import { maximumMediaPlays, mediaPlayCount } from "../response-limits.js";
4
+
5
+ function parseBooleanAttribute(value: string | undefined): boolean | undefined {
6
+ if (value === "true" || value === "1") return true;
7
+ if (value === "false" || value === "0") return false;
8
+ return undefined;
9
+ }
10
+
11
+ export interface MediaResponseBinding {
12
+ currentValue?: QtiValue | undefined;
13
+ update?: ((value: QtiValue) => void) | undefined;
14
+ isCompleted?: (() => boolean) | undefined;
15
+ }
16
+
17
+ export function renderObjectAsset(
18
+ interaction: QtiInteraction,
19
+ mediaResponse: MediaResponseBinding = {},
20
+ ): HTMLElement {
21
+ const object = interaction.object;
22
+ const label = interaction.prompt ?? object?.text ?? "Media interaction";
23
+ const mediaType = object ? mediaElementType(object) : undefined;
24
+
25
+ if (object && mediaType === "audio") {
26
+ const audio = document.createElement("audio");
27
+ configureMediaElement(audio, interaction, object, label, mediaResponse);
28
+ audio.style.inlineSize = "100%";
29
+ return audio;
30
+ }
31
+
32
+ if (object && mediaType === "video") {
33
+ const video = document.createElement("video");
34
+ configureMediaElement(video, interaction, object, label, mediaResponse);
35
+ if (object.width) video.width = Number(object.width);
36
+ if (object.height) video.height = Number(object.height);
37
+ return video;
38
+ }
39
+
40
+ if (object?.data && objectIsImage(object)) {
41
+ const image = document.createElement("img");
42
+ image.src = object.data;
43
+ image.alt = label;
44
+ image.style.maxInlineSize = "100%";
45
+ image.style.blockSize = "auto";
46
+ if (object.width) image.width = Number(object.width);
47
+ if (object.height) image.height = Number(object.height);
48
+ return image;
49
+ }
50
+
51
+ const group = document.createElement("div");
52
+ group.role = "group";
53
+ group.setAttribute("aria-label", label);
54
+ const fallbackHref = object?.data ?? object?.sources.find((source) => source.src)?.src;
55
+ if (fallbackHref) {
56
+ const link = document.createElement("a");
57
+ link.href = fallbackHref;
58
+ link.textContent = object?.text || fallbackHref;
59
+ group.append(link);
60
+ } else {
61
+ group.textContent = label;
62
+ }
63
+ return group;
64
+ }
65
+
66
+ function configureMediaElement(
67
+ media: HTMLAudioElement | HTMLVideoElement,
68
+ interaction: QtiInteraction,
69
+ object: QtiObjectAsset,
70
+ label: string,
71
+ mediaResponse: MediaResponseBinding,
72
+ ): void {
73
+ media.controls = mediaControlsMode(interaction, object) !== "none";
74
+ media.preload = "none";
75
+ media.autoplay = parseBooleanAttribute(interaction.attributes.autostart) ?? false;
76
+ media.loop = parseBooleanAttribute(interaction.attributes.loop) ?? false;
77
+ media.setAttribute("aria-label", label);
78
+ media.style.maxInlineSize = "100%";
79
+ copyMediaDataAttributes(media, interaction.attributes);
80
+ copyMediaDataAttributes(media, object.attributes);
81
+
82
+ if (object.data) media.src = object.data;
83
+ for (const source of object.sources) {
84
+ if (!source.src) continue;
85
+ const sourceElement = document.createElement("source");
86
+ sourceElement.src = source.src;
87
+ if (source.type) sourceElement.type = source.type;
88
+ copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
89
+ media.append(sourceElement);
90
+ }
91
+ for (const track of object.tracks) {
92
+ if (!track.src) continue;
93
+ const trackElement = document.createElement("track");
94
+ trackElement.src = track.src;
95
+ if (track.kind) trackElement.kind = track.kind;
96
+ if (track.srclang) trackElement.srclang = track.srclang;
97
+ if (track.label) trackElement.label = track.label;
98
+ if (track.default) trackElement.default = true;
99
+ copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
100
+ media.append(trackElement);
101
+ }
102
+
103
+ bindMediaPlayCount(media, interaction, mediaResponse);
104
+ }
105
+
106
+ function copyMediaDataAttributes(element: HTMLElement, attributes: Record<string, string>): void {
107
+ for (const [name, value] of Object.entries(attributes)) {
108
+ if (!name.startsWith("data-")) continue;
109
+ element.setAttribute(name, value);
110
+ }
111
+ }
112
+
113
+ const sourceAttributeNames = new Set(["src", "srcset", "type"]);
114
+ const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
115
+
116
+ function copySafeMediaChildAttributes(
117
+ element: HTMLElement,
118
+ attributes: Record<string, string>,
119
+ controlledNames: Set<string>,
120
+ ): void {
121
+ for (const [name, value] of Object.entries(attributes)) {
122
+ const normalizedName = name.toLowerCase();
123
+ if (controlledNames.has(normalizedName)) continue;
124
+ if (
125
+ normalizedName === "class" ||
126
+ normalizedName === "id" ||
127
+ normalizedName === "title" ||
128
+ normalizedName === "media" ||
129
+ normalizedName === "sizes" ||
130
+ normalizedName.startsWith("data-")
131
+ ) {
132
+ element.setAttribute(name, value);
133
+ }
134
+ }
135
+ }
136
+
137
+ function mediaElementType(object: QtiObjectAsset): "audio" | "video" | undefined {
138
+ const types = [object.type, ...object.sources.map((source) => source.type)].filter(
139
+ (value): value is string => Boolean(value),
140
+ );
141
+ if (types.some((value) => value.startsWith("audio/"))) return "audio";
142
+ if (types.some((value) => value.startsWith("video/"))) return "video";
143
+ return undefined;
144
+ }
145
+
146
+ function mediaControlsMode(
147
+ interaction: QtiInteraction,
148
+ object: QtiObjectAsset,
149
+ ): string | undefined {
150
+ return (
151
+ interaction.attributes["data-qti-media-player-controls"] ??
152
+ object.attributes["data-qti-media-player-controls"]
153
+ );
154
+ }
155
+
156
+ function bindMediaPlayCount(
157
+ media: HTMLAudioElement | HTMLVideoElement,
158
+ interaction: QtiInteraction,
159
+ mediaResponse: MediaResponseBinding,
160
+ ): void {
161
+ if (!mediaResponse.update) return;
162
+ let playCount = mediaPlayCount(mediaResponse.currentValue ?? null);
163
+ let activePlaySession = false;
164
+ let readyAfterEnded = false;
165
+ const maximum = maximumMediaPlays(interaction);
166
+
167
+ const syncState = () => {
168
+ media.dataset.playCount = String(playCount);
169
+ if (maximum !== undefined && playCount >= maximum && !activePlaySession) {
170
+ media.dataset.maxPlaysReached = "true";
171
+ } else {
172
+ delete media.dataset.maxPlaysReached;
173
+ }
174
+ };
175
+
176
+ media.addEventListener("play", () => {
177
+ if (mediaResponse.isCompleted?.()) {
178
+ return;
179
+ }
180
+ if (!activePlaySession && maximum !== undefined && playCount >= maximum) {
181
+ media.pause();
182
+ syncState();
183
+ return;
184
+ }
185
+ if (!activePlaySession && (readyAfterEnded || media.currentTime <= 0.25)) {
186
+ playCount += 1;
187
+ mediaResponse.update?.(playCount);
188
+ activePlaySession = true;
189
+ readyAfterEnded = false;
190
+ syncState();
191
+ return;
192
+ }
193
+ activePlaySession = true;
194
+ readyAfterEnded = false;
195
+ syncState();
196
+ });
197
+
198
+ media.addEventListener("ended", () => {
199
+ activePlaySession = false;
200
+ readyAfterEnded = true;
201
+ syncState();
202
+ });
203
+
204
+ media.addEventListener("seeked", () => {
205
+ if (!media.paused || media.currentTime > 0.25) return;
206
+ activePlaySession = false;
207
+ readyAfterEnded = false;
208
+ syncState();
209
+ });
210
+
211
+ syncState();
212
+ }
@@ -0,0 +1,147 @@
1
+ import type { QtiChoice, QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import { removeButton } from "../controls/remove-button.js";
3
+ import {
4
+ missingChoicesMessage,
5
+ readableType,
6
+ responseGroup,
7
+ valueToStrings,
8
+ } from "../interaction-support.js";
9
+ import type { QtiPlayerMessages } from "../player-messages.js";
10
+ import {
11
+ choiceText,
12
+ pairRegionLabels,
13
+ sourceChoices,
14
+ targetChoices,
15
+ tokenButton,
16
+ tokenRegion,
17
+ } from "./shared.js";
18
+
19
+ export function renderPairResponse(
20
+ interaction: QtiInteraction,
21
+ update: (value: QtiValue) => void,
22
+ currentValue: QtiValue,
23
+ messages: QtiPlayerMessages,
24
+ ): HTMLElement {
25
+ const group = responseGroup();
26
+
27
+ const sources = sourceChoices(interaction);
28
+ const targets = targetChoices(interaction);
29
+ if (sources.length === 0 || targets.length === 0) {
30
+ group.append(missingChoicesMessage(interaction));
31
+ return group;
32
+ }
33
+ const selectedPairs: string[] = valueToStrings(currentValue);
34
+ let selectedSource: QtiChoice | undefined;
35
+ let selectedTarget: QtiChoice | undefined;
36
+ const labels = pairRegionLabels(interaction);
37
+
38
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} sources`, labels.source);
39
+ const targetRegion = tokenRegion(`${readableType(interaction.type)} targets`, labels.target);
40
+ const selector = document.createElement("div");
41
+ selector.className = "qti3-pair-selector";
42
+ const pairList = document.createElement("ul");
43
+ pairList.className = "qti3-pair-list";
44
+ pairList.setAttribute("aria-label", `${readableType(interaction.type)} selected pairs`);
45
+ let draggedSource: string | undefined;
46
+
47
+ const commit = () => {
48
+ if (interaction.responseCardinality === "single") update(selectedPairs[0] ?? null);
49
+ else update([...selectedPairs]);
50
+ };
51
+ const syncPressed = () => {
52
+ for (const button of sourceRegion.querySelectorAll<HTMLButtonElement>("button")) {
53
+ button.setAttribute(
54
+ "aria-pressed",
55
+ button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false",
56
+ );
57
+ }
58
+ for (const button of targetRegion.querySelectorAll<HTMLButtonElement>("button")) {
59
+ button.setAttribute(
60
+ "aria-pressed",
61
+ button.dataset.choiceIdentifier === selectedTarget?.identifier ? "true" : "false",
62
+ );
63
+ }
64
+ };
65
+ const addSelectedPair = () => {
66
+ if (!selectedSource || !selectedTarget) return;
67
+ const pair = `${selectedSource.identifier} ${selectedTarget.identifier}`;
68
+ if (!selectedPairs.includes(pair)) selectedPairs.push(pair);
69
+ selectedSource = undefined;
70
+ selectedTarget = undefined;
71
+ syncPressed();
72
+ renderPairs();
73
+ commit();
74
+ };
75
+ const addPair = (sourceIdentifier: string | undefined, targetIdentifier: string): void => {
76
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
77
+ const target = targets.find((choice) => choice.identifier === targetIdentifier);
78
+ if (!source || !target) return;
79
+ selectedSource = source;
80
+ selectedTarget = target;
81
+ addSelectedPair();
82
+ };
83
+ const renderPairs = () => {
84
+ pairList.replaceChildren(
85
+ ...selectedPairs.map((pair) => {
86
+ const [source, target] = pair.split(" ");
87
+ const item = document.createElement("li");
88
+ item.className = "qti3-pair-chip";
89
+ const text = document.createElement("span");
90
+ text.textContent = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
91
+ const remove = removeButton(text.textContent, messages);
92
+ remove.addEventListener("click", () => {
93
+ const index = selectedPairs.indexOf(pair);
94
+ if (index >= 0) selectedPairs.splice(index, 1);
95
+ renderPairs();
96
+ commit();
97
+ });
98
+ item.append(text, remove);
99
+ return item;
100
+ }),
101
+ );
102
+ };
103
+
104
+ for (const choice of sources) {
105
+ const button = tokenButton(choice);
106
+ button.draggable = true;
107
+ button.addEventListener("dragstart", (event) => {
108
+ draggedSource = choice.identifier;
109
+ event.dataTransfer?.setData("text/plain", choice.identifier);
110
+ event.dataTransfer?.setDragImage(button, 8, 8);
111
+ });
112
+ button.addEventListener("dragend", () => {
113
+ draggedSource = undefined;
114
+ syncPressed();
115
+ });
116
+ button.addEventListener("click", () => {
117
+ selectedSource = choice;
118
+ syncPressed();
119
+ addSelectedPair();
120
+ });
121
+ sourceRegion.append(button);
122
+ }
123
+ for (const choice of targets) {
124
+ const button = tokenButton(choice);
125
+ button.addEventListener("dragover", (event) => {
126
+ event.preventDefault();
127
+ button.classList.add("qti3-drop-target");
128
+ });
129
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
130
+ button.addEventListener("drop", (event) => {
131
+ event.preventDefault();
132
+ button.classList.remove("qti3-drop-target");
133
+ addPair(event.dataTransfer?.getData("text/plain") || draggedSource, choice.identifier);
134
+ });
135
+ button.addEventListener("click", () => {
136
+ selectedTarget = choice;
137
+ syncPressed();
138
+ addSelectedPair();
139
+ });
140
+ targetRegion.append(button);
141
+ }
142
+
143
+ selector.append(sourceRegion, targetRegion);
144
+ renderPairs();
145
+ group.append(selector, pairList);
146
+ return group;
147
+ }
@@ -0,0 +1,41 @@
1
+ import type { QtiObjectAsset, QtiValue } from "@longsightgroup/qti3-core";
2
+ import { valueToStrings } from "../interaction-support.js";
3
+
4
+ export function parsePointValue(value: QtiValue): { x: number; y: number } | undefined {
5
+ const [raw] = valueToStrings(value);
6
+ return parsePointString(raw);
7
+ }
8
+
9
+ export function parsePointValues(value: QtiValue): Array<{ x: number; y: number }> {
10
+ return valueToStrings(value).flatMap((raw) => {
11
+ const point = parsePointString(raw);
12
+ return point ? [point] : [];
13
+ });
14
+ }
15
+
16
+ function parsePointString(raw: string | undefined): { x: number; y: number } | undefined {
17
+ if (!raw) return undefined;
18
+ const values = raw.split(/\s+/).map(Number);
19
+ const x = values[0];
20
+ const y = values[1];
21
+ if (typeof x !== "number" || typeof y !== "number") return undefined;
22
+ if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined;
23
+ return { x, y };
24
+ }
25
+
26
+ export function pointToString(point: { x: number; y: number } | undefined): string {
27
+ return point ? `${point.x} ${point.y}` : "";
28
+ }
29
+
30
+ function dimension(value: string | undefined, fallback: number): number {
31
+ const parsed = Number(value);
32
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
33
+ }
34
+
35
+ export function objectAssetWidth(object: QtiObjectAsset | undefined, fallback: number): number {
36
+ return dimension(object?.width, fallback);
37
+ }
38
+
39
+ export function objectAssetHeight(object: QtiObjectAsset | undefined, fallback: number): number {
40
+ return dimension(object?.height, fallback);
41
+ }