@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,337 @@
1
+ import type { QtiChoice, QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import {
3
+ applyGraphicSurfaceLayout,
4
+ appendGraphicObjectImage,
5
+ missingChoicesMessage,
6
+ objectHeight,
7
+ objectWidth,
8
+ placeHotspotButton,
9
+ readableType,
10
+ responseGroup,
11
+ valueToStrings,
12
+ } from "../interaction-support.js";
13
+ import { parseUnlimitedMaximum } from "../response-limits.js";
14
+ import { appendGraphicContext } from "./graphic-context.js";
15
+ import { appendInlineControl, normalizeInlineSegmentText } from "./inline-controls.js";
16
+ import { sourceChoices, targetChoices, tokenButton, tokenRegion } from "./shared.js";
17
+
18
+ function positivePixelValue(value: string | undefined): number | undefined {
19
+ const parsed = Number(value);
20
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
21
+ }
22
+
23
+ function graphicGapLabelBlockSize(sources: QtiChoice[]): number {
24
+ const maxLength = Math.max(
25
+ 0,
26
+ ...sources.map((source) => (source.text || source.identifier).trim().length),
27
+ );
28
+ const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
29
+ return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
30
+ }
31
+
32
+ export function renderGapMatchResponse(
33
+ interaction: QtiInteraction,
34
+ update: (value: QtiValue) => void,
35
+ currentValue: QtiValue,
36
+ ): HTMLElement {
37
+ if (
38
+ interaction.type === "graphicGapMatch" &&
39
+ interaction.object &&
40
+ interaction.choices.some((choice) => choice.role === "hotspot")
41
+ ) {
42
+ return renderGraphicGapMatchResponse(interaction, update, currentValue);
43
+ }
44
+
45
+ const group = responseGroup();
46
+ appendGraphicContext(group, interaction);
47
+ const sources = sourceChoices(interaction);
48
+ const gaps = targetChoices(interaction);
49
+ if (sources.length === 0 || gaps.length === 0) {
50
+ group.append(missingChoicesMessage(interaction));
51
+ return group;
52
+ }
53
+ const assignments = new Map<string, QtiChoice>();
54
+ let selectedSource: QtiChoice | undefined;
55
+ let draggedSource: string | undefined;
56
+
57
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
58
+ const gapRegion = document.createElement("div");
59
+ gapRegion.className = "qti3-gap-region qti3-gap-passage";
60
+ gapRegion.role = "group";
61
+ gapRegion.setAttribute("aria-label", `${readableType(interaction.type)} targets`);
62
+ for (const pair of valueToStrings(currentValue)) {
63
+ const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
64
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
65
+ if (source && gapIdentifier) assignments.set(gapIdentifier, source);
66
+ }
67
+
68
+ const commit = () => {
69
+ update(
70
+ [...assignments.entries()].map(
71
+ ([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`,
72
+ ),
73
+ );
74
+ };
75
+ const syncSources = () => {
76
+ for (const button of sourceRegion.querySelectorAll<HTMLButtonElement>("button")) {
77
+ button.setAttribute(
78
+ "aria-pressed",
79
+ button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false",
80
+ );
81
+ }
82
+ };
83
+ const assign = (gap: QtiChoice, sourceIdentifier: string | undefined) => {
84
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
85
+ if (!source) return;
86
+ assignments.set(gap.identifier, source);
87
+ selectedSource = undefined;
88
+ syncSources();
89
+ renderGaps();
90
+ commit();
91
+ };
92
+ const gapControl = (gap: QtiChoice, index: number) => {
93
+ const assigned = assignments.get(gap.identifier);
94
+ const gapLabel = `Gap ${index + 1}`;
95
+ const target = document.createElement("span");
96
+ target.className = "qti3-gap-target";
97
+ target.dataset.gapIdentifier = gap.identifier;
98
+ target.addEventListener("dragover", (event) => {
99
+ event.preventDefault();
100
+ target.classList.add("qti3-drop-target");
101
+ });
102
+ target.addEventListener("dragleave", () => target.classList.remove("qti3-drop-target"));
103
+ target.addEventListener("drop", (event) => {
104
+ event.preventDefault();
105
+ target.classList.remove("qti3-drop-target");
106
+ assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
107
+ });
108
+
109
+ const button = document.createElement("button");
110
+ button.type = "button";
111
+ button.className = "qti3-gap-button";
112
+ button.textContent = assigned ? assigned.text : "";
113
+ button.setAttribute(
114
+ "aria-label",
115
+ assigned ? `${gapLabel}, assigned ${assigned.text}` : `${gapLabel}, empty`,
116
+ );
117
+ button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
118
+ button.addEventListener("keydown", (event) => {
119
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
120
+ if (!assignments.has(gap.identifier)) return;
121
+ event.preventDefault();
122
+ assignments.delete(gap.identifier);
123
+ renderGaps();
124
+ commit();
125
+ });
126
+ target.append(button);
127
+ return target;
128
+ };
129
+ const renderGaps = () => {
130
+ const segments = interaction.gapMatchSegments ?? [];
131
+ const hasInlineGaps = segments.some((segment) => segment.kind === "gap");
132
+ if (!hasInlineGaps) {
133
+ gapRegion.replaceChildren(...gaps.map((gap, index) => gapControl(gap, index)));
134
+ return;
135
+ }
136
+
137
+ const content: Array<Node | string> = [];
138
+ for (const [segmentIndex, segment] of segments.entries()) {
139
+ if (segment.kind === "text") {
140
+ content.push(document.createTextNode(normalizeInlineSegmentText(segment.text)));
141
+ continue;
142
+ }
143
+
144
+ const gapIndex = gaps.findIndex((gap) => gap.identifier === segment.identifier);
145
+ const gap = gaps[gapIndex];
146
+ if (gap) {
147
+ appendInlineControl(content, gapControl(gap, gapIndex), segments[segmentIndex + 1]);
148
+ }
149
+ }
150
+ gapRegion.replaceChildren(...content);
151
+ };
152
+
153
+ for (const source of sources) {
154
+ const button = tokenButton(source);
155
+ button.draggable = true;
156
+ button.addEventListener("dragstart", (event) => {
157
+ draggedSource = source.identifier;
158
+ event.dataTransfer?.setData("text/plain", source.identifier);
159
+ });
160
+ button.addEventListener("click", () => {
161
+ selectedSource = source;
162
+ syncSources();
163
+ });
164
+ sourceRegion.append(button);
165
+ }
166
+
167
+ renderGaps();
168
+ group.append(sourceRegion, gapRegion);
169
+ return group;
170
+ }
171
+
172
+ function renderGraphicGapMatchResponse(
173
+ interaction: QtiInteraction,
174
+ update: (value: QtiValue) => void,
175
+ currentValue: QtiValue,
176
+ ): HTMLElement {
177
+ const group = responseGroup();
178
+ const width = objectWidth(interaction);
179
+ const height = objectHeight(interaction);
180
+ const sources = sourceChoices(interaction);
181
+ const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
182
+ if (sources.length === 0 || gaps.length === 0) {
183
+ group.append(missingChoicesMessage(interaction));
184
+ return group;
185
+ }
186
+ const assignments = new Map<string, QtiChoice>();
187
+ let selectedSource: QtiChoice | undefined;
188
+ let draggedSource: string | undefined;
189
+
190
+ for (const pair of valueToStrings(currentValue)) {
191
+ const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
192
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
193
+ if (source && gapIdentifier) assignments.set(gapIdentifier, source);
194
+ }
195
+
196
+ const surface = document.createElement("div");
197
+ applyGraphicSurfaceLayout(
198
+ surface,
199
+ width,
200
+ height,
201
+ "qti3-graphic-context",
202
+ "qti3-graphic-gap-match-surface",
203
+ );
204
+ surface.role = "group";
205
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
206
+ surface.style.overflow = "visible";
207
+ surface.style.setProperty(
208
+ "--qti3-graphic-gap-label-block-size",
209
+ `${graphicGapLabelBlockSize(sources)}rem`,
210
+ );
211
+
212
+ if (interaction.object) {
213
+ appendGraphicObjectImage(
214
+ surface,
215
+ interaction.object,
216
+ interaction.object.text || `${readableType(interaction.type)} image`,
217
+ );
218
+ }
219
+
220
+ const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
221
+ sourceRegion.classList.add("qti3-graphic-gap-source-region");
222
+ const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
223
+ if (choicesWidth !== undefined) sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
224
+
225
+ const summary = document.createElement("p");
226
+ summary.className = "qti3-selection-summary";
227
+ summary.setAttribute("aria-live", "polite");
228
+
229
+ const commit = () => {
230
+ update(
231
+ [...assignments.entries()].map(
232
+ ([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`,
233
+ ),
234
+ );
235
+ };
236
+ const syncSources = () => {
237
+ for (const button of sourceRegion.querySelectorAll<HTMLButtonElement>("button")) {
238
+ button.setAttribute(
239
+ "aria-pressed",
240
+ button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false",
241
+ );
242
+ }
243
+ };
244
+ const clearSourceIfSingleUse = (source: QtiChoice, keepGapIdentifier: string) => {
245
+ if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1) return;
246
+ for (const [gapIdentifier, assigned] of assignments.entries()) {
247
+ if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
248
+ assignments.delete(gapIdentifier);
249
+ }
250
+ }
251
+ };
252
+ const assign = (gap: QtiChoice, sourceIdentifier: string | undefined) => {
253
+ const source = sources.find((choice) => choice.identifier === sourceIdentifier);
254
+ if (!source) return;
255
+ clearSourceIfSingleUse(source, gap.identifier);
256
+ assignments.set(gap.identifier, source);
257
+ selectedSource = undefined;
258
+ syncSources();
259
+ renderTargets();
260
+ commit();
261
+ };
262
+ const targetLabel = (gap: QtiChoice, index: number) =>
263
+ gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
264
+ const renderTargetButton = (gap: QtiChoice, index: number): HTMLButtonElement => {
265
+ const assigned = assignments.get(gap.identifier);
266
+ const label = targetLabel(gap, index);
267
+ const button = document.createElement("button");
268
+ button.type = "button";
269
+ button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
270
+ button.dataset.gapIdentifier = gap.identifier;
271
+ button.dataset.selected = assigned ? "true" : "false";
272
+ button.setAttribute(
273
+ "aria-label",
274
+ assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`,
275
+ );
276
+ button.addEventListener("dragover", (event) => {
277
+ event.preventDefault();
278
+ button.classList.add("qti3-drop-target");
279
+ });
280
+ button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
281
+ button.addEventListener("drop", (event) => {
282
+ event.preventDefault();
283
+ button.classList.remove("qti3-drop-target");
284
+ assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
285
+ });
286
+ button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
287
+ button.addEventListener("keydown", (event) => {
288
+ if (event.key !== "Delete" && event.key !== "Backspace") return;
289
+ if (!assignments.has(gap.identifier)) return;
290
+ event.preventDefault();
291
+ assignments.delete(gap.identifier);
292
+ renderTargets();
293
+ commit();
294
+ });
295
+ placeHotspotButton(button, gap, width, height);
296
+ if (assigned) {
297
+ const assignedLabel = document.createElement("span");
298
+ assignedLabel.className = "qti3-graphic-gap-label";
299
+ assignedLabel.textContent = assigned.text;
300
+ button.append(assignedLabel);
301
+ }
302
+ return button;
303
+ };
304
+ const renderTargets = () => {
305
+ surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
306
+ for (const [index, gap] of gaps.entries()) {
307
+ surface.append(renderTargetButton(gap, index));
308
+ }
309
+ summary.textContent =
310
+ assignments.size > 0
311
+ ? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
312
+ : "No labels placed.";
313
+ };
314
+
315
+ for (const source of sources) {
316
+ const button = tokenButton(source);
317
+ button.draggable = true;
318
+ button.addEventListener("dragstart", (event) => {
319
+ draggedSource = source.identifier;
320
+ event.dataTransfer?.setData("text/plain", source.identifier);
321
+ event.dataTransfer?.setDragImage(button, 8, 8);
322
+ });
323
+ button.addEventListener("dragend", () => {
324
+ draggedSource = undefined;
325
+ syncSources();
326
+ });
327
+ button.addEventListener("click", () => {
328
+ selectedSource = source;
329
+ syncSources();
330
+ });
331
+ sourceRegion.append(button);
332
+ }
333
+
334
+ renderTargets();
335
+ group.append(surface, sourceRegion, summary);
336
+ return group;
337
+ }
@@ -0,0 +1,324 @@
1
+ import type { QtiChoice, QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import { removeButton } from "../controls/remove-button.js";
3
+ import {
4
+ applyGraphicSurfaceLayout,
5
+ appendGraphicObjectImage,
6
+ interactionChoices,
7
+ missingChoicesMessage,
8
+ hotspotAccessibleLabel,
9
+ hotspotCenter,
10
+ hotspotDisplayLabel,
11
+ objectHeight,
12
+ objectWidth,
13
+ placeHotspotButton,
14
+ readableType,
15
+ responseGroup,
16
+ valueToStrings,
17
+ } from "../interaction-support.js";
18
+ import type { QtiPlayerMessages } from "../player-messages.js";
19
+ import { exceedsHotspotMatchMax, maximumAllowedResponses } from "../response-limits.js";
20
+
21
+ export function renderGraphicAssociateResponse(
22
+ interaction: QtiInteraction,
23
+ update: (value: QtiValue) => void,
24
+ currentValue: QtiValue,
25
+ messages: QtiPlayerMessages,
26
+ ): HTMLElement {
27
+ const group = responseGroup();
28
+
29
+ const width = objectWidth(interaction);
30
+ const height = objectHeight(interaction);
31
+ const choices = interactionChoices(interaction).filter((choice) => choice.role === "hotspot");
32
+ if (choices.length === 0) {
33
+ group.append(missingChoicesMessage(interaction));
34
+ return group;
35
+ }
36
+ const selectedPairs = valueToStrings(currentValue);
37
+ const maximumAssociations =
38
+ interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
39
+ let selectedHotspot: QtiChoice | undefined;
40
+ let draggedHotspot: QtiChoice | undefined;
41
+ let dragPointerId: number | undefined;
42
+ let dragStart: { x: number; y: number } | undefined;
43
+ let dragStarted = false;
44
+ let suppressNextClick = false;
45
+ let previewLine: SVGLineElement | undefined;
46
+
47
+ const surface = document.createElement("div");
48
+ applyGraphicSurfaceLayout(surface, width, height, "qti3-graphic-associate-surface");
49
+ surface.role = "group";
50
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} hotspots`);
51
+
52
+ const object = interaction.object;
53
+ if (object) {
54
+ appendGraphicObjectImage(
55
+ surface,
56
+ object,
57
+ object.text || `${readableType(interaction.type)} image`,
58
+ );
59
+ }
60
+
61
+ const connections = document.createElementNS("http://www.w3.org/2000/svg", "svg");
62
+ connections.classList.add("qti3-graphic-associate-lines");
63
+ connections.setAttribute("viewBox", `0 0 ${width} ${height}`);
64
+ connections.setAttribute("aria-hidden", "true");
65
+ surface.append(connections);
66
+
67
+ const summary = document.createElement("p");
68
+ summary.className = "qti3-selection-summary";
69
+ summary.setAttribute("aria-live", "polite");
70
+ const pairList = document.createElement("ul");
71
+ pairList.className = "qti3-pair-list";
72
+ pairList.setAttribute("aria-label", `${readableType(interaction.type)} selected pairs`);
73
+
74
+ const commit = () => {
75
+ if (interaction.responseCardinality === "single") update(selectedPairs[0] ?? null);
76
+ else update([...selectedPairs]);
77
+ };
78
+ const removePair = (pair: string) => {
79
+ const index = selectedPairs.indexOf(pair);
80
+ if (index < 0) return;
81
+ selectedPairs.splice(index, 1);
82
+ renderState();
83
+ commit();
84
+ };
85
+ const removePairsForHotspot = (identifier: string) => {
86
+ let removed = false;
87
+ for (let index = selectedPairs.length - 1; index >= 0; index -= 1) {
88
+ const [source, target] = selectedPairs[index]?.split(" ") ?? [];
89
+ if (source === identifier || target === identifier) {
90
+ selectedPairs.splice(index, 1);
91
+ removed = true;
92
+ }
93
+ }
94
+ if (!removed) return;
95
+ renderState();
96
+ commit();
97
+ };
98
+ const addPair = (source: QtiChoice, target: QtiChoice) => {
99
+ if (source.identifier === target.identifier) {
100
+ selectedHotspot = undefined;
101
+ renderState();
102
+ return;
103
+ }
104
+ const pair = `${source.identifier} ${target.identifier}`;
105
+ if (!selectedPairs.includes(pair)) {
106
+ if (interaction.responseCardinality === "single") selectedPairs.splice(0);
107
+ if (
108
+ maximumAssociations !== undefined &&
109
+ selectedPairs.length >= maximumAssociations &&
110
+ interaction.responseCardinality !== "single"
111
+ ) {
112
+ selectedHotspot = undefined;
113
+ renderState();
114
+ return;
115
+ }
116
+ if (
117
+ exceedsHotspotMatchMax(source, selectedPairs) ||
118
+ exceedsHotspotMatchMax(target, selectedPairs)
119
+ ) {
120
+ selectedHotspot = undefined;
121
+ renderState();
122
+ return;
123
+ }
124
+ selectedPairs.push(pair);
125
+ }
126
+ selectedHotspot = undefined;
127
+ renderState();
128
+ commit();
129
+ };
130
+ const authoredPointFromPointer = (event: PointerEvent) => {
131
+ const rect = surface.getBoundingClientRect();
132
+ return {
133
+ x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
134
+ y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
135
+ };
136
+ };
137
+ const removePreviewLine = () => {
138
+ previewLine?.remove();
139
+ previewLine = undefined;
140
+ };
141
+ const suppressFollowingClick = () => {
142
+ suppressNextClick = true;
143
+ setTimeout(() => {
144
+ suppressNextClick = false;
145
+ }, 0);
146
+ };
147
+ const updatePreviewLine = (source: QtiChoice, event: PointerEvent) => {
148
+ const start = hotspotCenter(source, width, height);
149
+ const end = authoredPointFromPointer(event);
150
+ if (!previewLine) {
151
+ previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
152
+ previewLine.dataset.preview = "true";
153
+ connections.append(previewLine);
154
+ }
155
+ previewLine.setAttribute("x1", String(start.x));
156
+ previewLine.setAttribute("y1", String(start.y));
157
+ previewLine.setAttribute("x2", String(end.x));
158
+ previewLine.setAttribute("y2", String(end.y));
159
+ };
160
+ const hotspotFromPointer = (event: PointerEvent) => {
161
+ const element = document.elementFromPoint(event.clientX, event.clientY);
162
+ const button = element?.closest<HTMLButtonElement>(".qti3-graphic-associate-hotspot");
163
+ const identifier = button?.dataset.choiceIdentifier;
164
+ return choices.find((choice) => choice.identifier === identifier);
165
+ };
166
+ const finishDrag = (event: PointerEvent, source: QtiChoice) => {
167
+ const target = hotspotFromPointer(event);
168
+ removePreviewLine();
169
+ if (target) {
170
+ addPair(source, target);
171
+ return;
172
+ }
173
+ selectedHotspot = undefined;
174
+ renderState();
175
+ };
176
+ const chooseHotspot = (choice: QtiChoice) => {
177
+ if (!selectedHotspot) {
178
+ selectedHotspot = choice;
179
+ renderState();
180
+ return;
181
+ }
182
+ addPair(selectedHotspot, choice);
183
+ };
184
+ const focusRelativeHotspot = (choice: QtiChoice, delta: number) => {
185
+ const index = choices.findIndex((entry) => entry.identifier === choice.identifier);
186
+ const next = choices[(index + delta + choices.length) % choices.length];
187
+ if (!next) return;
188
+ surface
189
+ .querySelector<HTMLButtonElement>(`[data-choice-identifier="${next.identifier}"]`)
190
+ ?.focus();
191
+ };
192
+ const renderState = () => {
193
+ connections.replaceChildren(
194
+ ...selectedPairs.flatMap((pair) => {
195
+ const [sourceIdentifier, targetIdentifier] = pair.split(" ");
196
+ const source = choices.find((choice) => choice.identifier === sourceIdentifier);
197
+ const target = choices.find((choice) => choice.identifier === targetIdentifier);
198
+ if (!source || !target) return [];
199
+ const start = hotspotCenter(source, width, height);
200
+ const end = hotspotCenter(target, width, height);
201
+ const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
202
+ line.setAttribute("x1", String(start.x));
203
+ line.setAttribute("y1", String(start.y));
204
+ line.setAttribute("x2", String(end.x));
205
+ line.setAttribute("y2", String(end.y));
206
+ return [line];
207
+ }),
208
+ );
209
+ for (const button of surface.querySelectorAll<HTMLButtonElement>(".qti3-hotspot-button")) {
210
+ const identifier = button.dataset.choiceIdentifier ?? "";
211
+ const isActive = identifier === selectedHotspot?.identifier;
212
+ const isPaired = selectedPairs.some((pair) => pair.split(" ").includes(identifier));
213
+ button.setAttribute("aria-pressed", isActive ? "true" : "false");
214
+ button.dataset.selected = isActive || isPaired ? "true" : "false";
215
+ }
216
+ summary.textContent = selectedHotspot
217
+ ? messages.hotspotSelectedChooseAnother({
218
+ label: hotspotDisplayLabel(selectedHotspot, choices),
219
+ })
220
+ : selectedPairs.length > 0
221
+ ? messages.associationsMade({ count: selectedPairs.length })
222
+ : messages.noAssociationsMade();
223
+ pairList.replaceChildren(
224
+ ...selectedPairs.map((pair) => {
225
+ const [source = "", target = ""] = pair.split(" ");
226
+ const sourceChoice = choices.find((choice) => choice.identifier === source);
227
+ const targetChoice = choices.find((choice) => choice.identifier === target);
228
+ const pairLabel = messages.associationPairLabel({
229
+ source: sourceChoice ? hotspotDisplayLabel(sourceChoice, choices) : source,
230
+ target: targetChoice ? hotspotDisplayLabel(targetChoice, choices) : target,
231
+ });
232
+ const item = document.createElement("li");
233
+ item.className = "qti3-pair-chip";
234
+ const text = document.createElement("span");
235
+ text.textContent = pairLabel;
236
+ const remove = removeButton(pairLabel, messages);
237
+ remove.addEventListener("click", () => removePair(pair));
238
+ item.append(text, remove);
239
+ return item;
240
+ }),
241
+ );
242
+ };
243
+
244
+ for (const [index, choice] of choices.entries()) {
245
+ const button = document.createElement("button");
246
+ button.type = "button";
247
+ button.className = "qti3-hotspot-button qti3-graphic-associate-hotspot";
248
+ button.dataset.choiceIdentifier = choice.identifier;
249
+ button.textContent = hotspotDisplayLabel(choice, choices);
250
+ button.title = hotspotAccessibleLabel(choice, index);
251
+ button.setAttribute("aria-pressed", "false");
252
+ button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
253
+ placeHotspotButton(button, choice, width, height);
254
+ button.addEventListener("click", (event) => {
255
+ if (suppressNextClick) {
256
+ suppressNextClick = false;
257
+ event.preventDefault();
258
+ return;
259
+ }
260
+ chooseHotspot(choice);
261
+ });
262
+ button.addEventListener("pointerdown", (event) => {
263
+ if (event.button !== 0) return;
264
+ draggedHotspot = choice;
265
+ dragPointerId = event.pointerId;
266
+ dragStart = { x: event.clientX, y: event.clientY };
267
+ dragStarted = false;
268
+ button.setPointerCapture(event.pointerId);
269
+ });
270
+ button.addEventListener("pointermove", (event) => {
271
+ if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart) return;
272
+ const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
273
+ if (!dragStarted && moved < 4) return;
274
+ if (!dragStarted) {
275
+ dragStarted = true;
276
+ suppressFollowingClick();
277
+ selectedHotspot = draggedHotspot;
278
+ renderState();
279
+ }
280
+ updatePreviewLine(draggedHotspot, event);
281
+ event.preventDefault();
282
+ });
283
+ button.addEventListener("pointerup", (event) => {
284
+ if (dragPointerId !== event.pointerId || !draggedHotspot) return;
285
+ const source = draggedHotspot;
286
+ draggedHotspot = undefined;
287
+ dragPointerId = undefined;
288
+ dragStart = undefined;
289
+ button.releasePointerCapture(event.pointerId);
290
+ if (!dragStarted) return;
291
+ dragStarted = false;
292
+ suppressFollowingClick();
293
+ finishDrag(event, source);
294
+ event.preventDefault();
295
+ });
296
+ button.addEventListener("pointercancel", (event) => {
297
+ if (dragPointerId !== event.pointerId) return;
298
+ draggedHotspot = undefined;
299
+ dragPointerId = undefined;
300
+ dragStart = undefined;
301
+ dragStarted = false;
302
+ removePreviewLine();
303
+ selectedHotspot = undefined;
304
+ renderState();
305
+ });
306
+ button.addEventListener("keydown", (event) => {
307
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
308
+ event.preventDefault();
309
+ focusRelativeHotspot(choice, 1);
310
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
311
+ event.preventDefault();
312
+ focusRelativeHotspot(choice, -1);
313
+ } else if (event.key === "Delete" || event.key === "Backspace") {
314
+ event.preventDefault();
315
+ removePairsForHotspot(choice.identifier);
316
+ }
317
+ });
318
+ surface.append(button);
319
+ }
320
+
321
+ renderState();
322
+ group.append(surface, summary, pairList);
323
+ return group;
324
+ }
@@ -0,0 +1,33 @@
1
+ import type { QtiInteraction } from "@longsightgroup/qti3-core";
2
+ import { objectIsImage } from "../interaction-support.js";
3
+
4
+ export function appendGraphicContext(group: HTMLElement, interaction: QtiInteraction): void {
5
+ if (!interaction.type.startsWith("graphic") || !interaction.object) return;
6
+ const context = document.createElement("div");
7
+ context.className = "qti3-graphic-context";
8
+ const object = interaction.object;
9
+ const label = interaction.prompt ?? object.text ?? "Graphic interaction";
10
+
11
+ if (object.data && objectIsImage(object)) {
12
+ const image = document.createElement("img");
13
+ image.src = object.data;
14
+ image.alt = label;
15
+ image.style.maxInlineSize = "100%";
16
+ image.style.blockSize = "auto";
17
+ if (object.width) image.width = Number(object.width);
18
+ if (object.height) image.height = Number(object.height);
19
+ context.append(image);
20
+ } else {
21
+ const fallbackHref = object.data ?? object.sources.find((source) => source.src)?.src;
22
+ if (fallbackHref) {
23
+ const link = document.createElement("a");
24
+ link.href = fallbackHref;
25
+ link.textContent = object.text || fallbackHref;
26
+ context.append(link);
27
+ } else {
28
+ context.textContent = label;
29
+ }
30
+ }
31
+
32
+ group.append(context);
33
+ }