@longsightgroup/qti3-player 0.2.1 → 0.4.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 (117) hide show
  1. package/README.md +7 -0
  2. package/dist/icons.d.ts +8 -0
  3. package/dist/icons.d.ts.map +1 -0
  4. package/dist/icons.js +45 -0
  5. package/dist/icons.js.map +1 -0
  6. package/dist/index.d.ts +3 -134
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -4712
  9. package/dist/index.js.map +1 -1
  10. package/dist/interaction-support.d.ts +34 -0
  11. package/dist/interaction-support.d.ts.map +1 -0
  12. package/dist/interaction-support.js +189 -0
  13. package/dist/interaction-support.js.map +1 -0
  14. package/dist/movement.d.ts +3 -0
  15. package/dist/movement.d.ts.map +1 -0
  16. package/dist/movement.js +21 -0
  17. package/dist/movement.js.map +1 -0
  18. package/dist/player-element.d.ts +60 -0
  19. package/dist/player-element.d.ts.map +1 -0
  20. package/dist/player-element.js +367 -0
  21. package/dist/player-element.js.map +1 -0
  22. package/dist/player-locale.d.ts +6 -0
  23. package/dist/player-locale.d.ts.map +1 -0
  24. package/dist/player-locale.js +205 -0
  25. package/dist/player-locale.js.map +1 -0
  26. package/dist/player-messages.d.ts +40 -0
  27. package/dist/player-messages.d.ts.map +1 -0
  28. package/dist/player-messages.js +2 -0
  29. package/dist/player-messages.js.map +1 -0
  30. package/dist/player-styles.d.ts +3 -0
  31. package/dist/player-styles.d.ts.map +1 -0
  32. package/dist/player-styles.js +24 -0
  33. package/dist/player-styles.js.map +1 -0
  34. package/dist/player-types.d.ts +71 -0
  35. package/dist/player-types.d.ts.map +1 -0
  36. package/dist/player-types.js +2 -0
  37. package/dist/player-types.js.map +1 -0
  38. package/dist/player-validation-dom.d.ts +3 -0
  39. package/dist/player-validation-dom.d.ts.map +1 -0
  40. package/dist/player-validation-dom.js +28 -0
  41. package/dist/player-validation-dom.js.map +1 -0
  42. package/dist/player-validation.d.ts +13 -0
  43. package/dist/player-validation.d.ts.map +1 -0
  44. package/dist/player-validation.js +123 -0
  45. package/dist/player-validation.js.map +1 -0
  46. package/dist/portable-custom-support.d.ts +11 -0
  47. package/dist/portable-custom-support.d.ts.map +1 -0
  48. package/dist/portable-custom-support.js +70 -0
  49. package/dist/portable-custom-support.js.map +1 -0
  50. package/dist/response-limits.d.ts +9 -0
  51. package/dist/response-limits.d.ts.map +1 -0
  52. package/dist/response-limits.js +44 -0
  53. package/dist/response-limits.js.map +1 -0
  54. package/package.json +4 -4
  55. package/src/content/content-dom.ts +274 -0
  56. package/src/content/content-renderer.ts +114 -0
  57. package/src/controls/remove-button.ts +13 -0
  58. package/src/icons.ts +47 -0
  59. package/src/index.ts +26 -5307
  60. package/src/interaction-support.ts +263 -0
  61. package/src/interactions/choice-interaction.ts +92 -0
  62. package/src/interactions/drawing-interaction.ts +447 -0
  63. package/src/interactions/end-attempt-interaction.ts +19 -0
  64. package/src/interactions/gap-match-interaction.ts +337 -0
  65. package/src/interactions/graphic-associate-interaction.ts +324 -0
  66. package/src/interactions/graphic-context.ts +33 -0
  67. package/src/interactions/hotspot-interaction.ts +87 -0
  68. package/src/interactions/hottext-interaction.ts +81 -0
  69. package/src/interactions/inline-choice-interaction.ts +45 -0
  70. package/src/interactions/inline-controls.ts +21 -0
  71. package/src/interactions/interaction-diagnostics.ts +159 -0
  72. package/src/interactions/interaction-dispatch.ts +9 -0
  73. package/src/interactions/interaction-label.ts +10 -0
  74. package/src/interactions/interaction-registry.ts +209 -0
  75. package/src/interactions/match-interaction.ts +199 -0
  76. package/src/interactions/object-asset.ts +212 -0
  77. package/src/interactions/pair-interaction.ts +147 -0
  78. package/src/interactions/point-value.ts +41 -0
  79. package/src/interactions/portable-custom-interaction.ts +139 -0
  80. package/src/interactions/position-object-interaction.ts +210 -0
  81. package/src/interactions/routing.ts +27 -0
  82. package/src/interactions/select-point-interaction.ts +185 -0
  83. package/src/interactions/shared.ts +56 -0
  84. package/src/interactions/text-interaction.ts +127 -0
  85. package/src/interactions/unsupported-interaction.ts +25 -0
  86. package/src/interactions/upload-interaction.ts +16 -0
  87. package/src/movement.ts +29 -0
  88. package/src/player/attempt-availability.ts +36 -0
  89. package/src/player/content-state.ts +74 -0
  90. package/src/player/dynamic-body.ts +40 -0
  91. package/src/player/feedback-panel.ts +23 -0
  92. package/src/player/fetch-xml.ts +8 -0
  93. package/src/player/interaction-render.ts +89 -0
  94. package/src/player/render-shell.ts +44 -0
  95. package/src/player/resolve-assets.ts +12 -0
  96. package/src/player/validation-messages.ts +42 -0
  97. package/src/player-element.ts +493 -0
  98. package/src/player-locale.ts +232 -0
  99. package/src/player-messages.ts +31 -0
  100. package/src/player-styles.ts +25 -0
  101. package/src/player-types.ts +99 -0
  102. package/src/player-validation-dom.ts +31 -0
  103. package/src/player-validation.ts +158 -0
  104. package/src/portable-custom-support.ts +74 -0
  105. package/src/reorder/a11y.ts +40 -0
  106. package/src/reorder/graphic-order-interaction.ts +260 -0
  107. package/src/reorder/list-controls.ts +114 -0
  108. package/src/reorder/order-interaction.ts +75 -0
  109. package/src/response-limits.ts +47 -0
  110. package/src/styles/base-styles.ts +117 -0
  111. package/src/styles/choice-hottext-styles.ts +75 -0
  112. package/src/styles/control-styles.ts +113 -0
  113. package/src/styles/drawing-styles.ts +29 -0
  114. package/src/styles/gap-match-styles.ts +32 -0
  115. package/src/styles/graphic-styles.ts +294 -0
  116. package/src/styles/match-pair-styles.ts +61 -0
  117. package/src/styles/text-slider-styles.ts +34 -0
@@ -0,0 +1,263 @@
1
+ import { qtiValueToIdentifierList } from "@longsightgroup/qti3-core";
2
+ import type {
3
+ QtiChoice,
4
+ QtiInteraction,
5
+ QtiObjectAsset,
6
+ QtiValue,
7
+ } from "@longsightgroup/qti3-core";
8
+ import { errorView } from "./player-validation.js";
9
+
10
+ export function responseGroup(className?: string): HTMLElement {
11
+ const group = document.createElement("div");
12
+ group.className = ["qti3-response-group", className].filter(Boolean).join(" ");
13
+ return group;
14
+ }
15
+
16
+ export function interactionChoices(interaction: QtiInteraction): QtiChoice[] {
17
+ return interaction.choices;
18
+ }
19
+
20
+ export function missingChoicesMessage(interaction: QtiInteraction): HTMLElement {
21
+ const identifier = interaction.responseIdentifier ? ` (${interaction.responseIdentifier})` : "";
22
+ return errorView(`No choices are defined for the ${interaction.type} interaction${identifier}.`);
23
+ }
24
+
25
+ export function applyGraphicSurfaceLayout(
26
+ surface: HTMLElement,
27
+ width: number,
28
+ height: number,
29
+ ...classNames: string[]
30
+ ): void {
31
+ surface.classList.add("qti3-graphic-surface", ...classNames);
32
+ applyResponsiveGraphicSize(surface, width, height);
33
+ }
34
+
35
+ export function applyResponsiveGraphicSize(
36
+ element: HTMLElement | SVGSVGElement,
37
+ width: number,
38
+ height: number,
39
+ ): void {
40
+ element.style.display = "block";
41
+ element.style.inlineSize = "100%";
42
+ element.style.maxInlineSize = `${width}px`;
43
+ element.style.aspectRatio = `${width} / ${height}`;
44
+ }
45
+
46
+ export function choiceSelector(identifier: string): string {
47
+ return `[data-choice-identifier="${CSS.escape(identifier)}"]`;
48
+ }
49
+
50
+ export function valueToStrings(value: QtiValue): string[] {
51
+ return qtiValueToIdentifierList(value);
52
+ }
53
+
54
+ export function orderChoicesFromValue(choices: QtiChoice[], value: QtiValue): QtiChoice[] {
55
+ const identifiers = valueToStrings(value);
56
+ if (identifiers.length === 0) return [...choices];
57
+ const byIdentifier = new Map(choices.map((choice) => [choice.identifier, choice]));
58
+ const ordered = identifiers
59
+ .map((identifier) => byIdentifier.get(identifier))
60
+ .filter((choice): choice is QtiChoice => Boolean(choice));
61
+ const used = new Set(ordered.map((choice) => choice.identifier));
62
+ ordered.push(...choices.filter((choice) => !used.has(choice.identifier)));
63
+ return ordered;
64
+ }
65
+
66
+ export function readableType(type: string): string {
67
+ return type
68
+ .replace(/[A-Z]/g, (letter) => ` ${letter.toLowerCase()}`)
69
+ .replace(/^./, (letter) => letter.toUpperCase());
70
+ }
71
+
72
+ function dimension(value: string | undefined, fallback: number): number {
73
+ const parsed = Number(value);
74
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
75
+ }
76
+
77
+ export function objectWidth(interaction: QtiInteraction): number {
78
+ return dimension(interaction.object?.width, 160);
79
+ }
80
+
81
+ export function objectHeight(interaction: QtiInteraction): number {
82
+ return dimension(interaction.object?.height, 120);
83
+ }
84
+
85
+ export function objectIsImage(object: QtiObjectAsset): boolean {
86
+ return Boolean(
87
+ object.type?.startsWith("image/") ||
88
+ object.data?.startsWith("data:image/") ||
89
+ /\.(svg|png|jpg|jpeg|gif|webp)(?:[?#].*)?$/i.test(object.data ?? ""),
90
+ );
91
+ }
92
+
93
+ export function appendGraphicObjectImage(
94
+ surface: HTMLElement,
95
+ object: QtiObjectAsset,
96
+ alt: string,
97
+ ): void {
98
+ if (!object.data || !objectIsImage(object)) return;
99
+ const image = document.createElement("img");
100
+ image.className = "qti3-graphic-object-image";
101
+ image.src = object.data;
102
+ image.alt = alt;
103
+ surface.append(image);
104
+ }
105
+
106
+ function percent(value: number, total: number): number {
107
+ if (total <= 0) return 0;
108
+ return (value / total) * 100;
109
+ }
110
+
111
+ export { percent };
112
+
113
+ function hotspotCoords(choice: QtiChoice): number[] {
114
+ return (choice.attributes.coords ?? "")
115
+ .split(",")
116
+ .map((value) => Number(value.trim()))
117
+ .filter((value) => Number.isFinite(value));
118
+ }
119
+
120
+ export function applyGraphicRegionPlacement(
121
+ element: HTMLElement,
122
+ placement: {
123
+ insetInlineStart: string;
124
+ insetBlockStart: string;
125
+ inlineSize?: string;
126
+ blockSize?: string;
127
+ shape?: string;
128
+ },
129
+ ): void {
130
+ element.style.setProperty("--qti3-graphic-region-inline-start", placement.insetInlineStart);
131
+ element.style.setProperty("--qti3-graphic-region-block-start", placement.insetBlockStart);
132
+ if (placement.inlineSize !== undefined) {
133
+ element.style.setProperty("--qti3-graphic-region-inline-size", placement.inlineSize);
134
+ } else {
135
+ element.style.removeProperty("--qti3-graphic-region-inline-size");
136
+ }
137
+ if (placement.blockSize !== undefined) {
138
+ element.style.setProperty("--qti3-graphic-region-block-size", placement.blockSize);
139
+ } else {
140
+ element.style.removeProperty("--qti3-graphic-region-block-size");
141
+ }
142
+ if (placement.shape) element.dataset.shape = placement.shape;
143
+ else delete element.dataset.shape;
144
+ }
145
+
146
+ export function applyPointMarkerPlacement(
147
+ marker: HTMLElement,
148
+ insetInlineStart: string,
149
+ insetBlockStart: string,
150
+ ): void {
151
+ marker.style.setProperty("--qti3-point-marker-inline-start", insetInlineStart);
152
+ marker.style.setProperty("--qti3-point-marker-block-start", insetBlockStart);
153
+ }
154
+
155
+ export function applyPositionObjectMarkerSize(
156
+ marker: HTMLElement,
157
+ inlineSize: number,
158
+ blockSize: number,
159
+ ): void {
160
+ marker.style.setProperty("--qti3-position-object-marker-inline-size", `${inlineSize}px`);
161
+ marker.style.setProperty("--qti3-position-object-marker-block-size", `${blockSize}px`);
162
+ }
163
+
164
+ export function applyPositionObjectMarkerPlacement(
165
+ marker: HTMLElement,
166
+ insetInlineStart: string,
167
+ insetBlockStart: string,
168
+ ): void {
169
+ marker.style.setProperty("--qti3-position-object-marker-inline-start", insetInlineStart);
170
+ marker.style.setProperty("--qti3-position-object-marker-block-start", insetBlockStart);
171
+ }
172
+
173
+ export function placeHotspotButton(
174
+ button: HTMLButtonElement,
175
+ choice: QtiChoice,
176
+ width: number,
177
+ height: number,
178
+ ): void {
179
+ const coords = hotspotCoords(choice);
180
+ const shape = choice.attributes.shape;
181
+
182
+ if (shape === "circle" && coords.length >= 3) {
183
+ const [x, y, radius] = coords as [number, number, number];
184
+ applyGraphicRegionPlacement(button, {
185
+ insetInlineStart: `${percent(x - radius, width)}%`,
186
+ insetBlockStart: `${percent(y - radius, height)}%`,
187
+ inlineSize: `${percent(radius * 2, width)}%`,
188
+ blockSize: `${percent(radius * 2, height)}%`,
189
+ shape: "circle",
190
+ });
191
+ return;
192
+ }
193
+
194
+ if (shape === "rect" && coords.length >= 4) {
195
+ const [left, top, right, bottom] = coords as [number, number, number, number];
196
+ applyGraphicRegionPlacement(button, {
197
+ insetInlineStart: `${percent(left, width)}%`,
198
+ insetBlockStart: `${percent(top, height)}%`,
199
+ inlineSize: `${percent(Math.max(1, right - left), width)}%`,
200
+ blockSize: `${percent(Math.max(1, bottom - top), height)}%`,
201
+ shape: "rect",
202
+ });
203
+ return;
204
+ }
205
+
206
+ if (shape === "poly" && coords.length >= 6) {
207
+ const xs = coords.filter((_, index) => index % 2 === 0);
208
+ const ys = coords.filter((_, index) => index % 2 === 1);
209
+ const left = Math.min(...xs);
210
+ const top = Math.min(...ys);
211
+ const right = Math.max(...xs);
212
+ const bottom = Math.max(...ys);
213
+ applyGraphicRegionPlacement(button, {
214
+ insetInlineStart: `${percent(left, width)}%`,
215
+ insetBlockStart: `${percent(top, height)}%`,
216
+ inlineSize: `${percent(Math.max(1, right - left), width)}%`,
217
+ blockSize: `${percent(Math.max(1, bottom - top), height)}%`,
218
+ shape: "poly",
219
+ });
220
+ return;
221
+ }
222
+
223
+ applyGraphicRegionPlacement(button, {
224
+ insetInlineStart: "0",
225
+ insetBlockStart: "0",
226
+ });
227
+ }
228
+
229
+ export function hotspotCenter(
230
+ choice: QtiChoice,
231
+ width: number,
232
+ height: number,
233
+ ): { x: number; y: number } {
234
+ const coords = hotspotCoords(choice);
235
+ const shape = choice.attributes.shape;
236
+ if ((shape === "circle" || shape === "ellipse") && coords.length >= 2) {
237
+ const [x, y] = coords as [number, number];
238
+ return { x, y };
239
+ }
240
+ if (shape === "rect" && coords.length >= 4) {
241
+ const [left, top, right, bottom] = coords as [number, number, number, number];
242
+ return { x: (left + right) / 2, y: (top + bottom) / 2 };
243
+ }
244
+ if (shape === "poly" && coords.length >= 6) {
245
+ const xs = coords.filter((_, index) => index % 2 === 0);
246
+ const ys = coords.filter((_, index) => index % 2 === 1);
247
+ return {
248
+ x: (Math.min(...xs) + Math.max(...xs)) / 2,
249
+ y: (Math.min(...ys) + Math.max(...ys)) / 2,
250
+ };
251
+ }
252
+ return { x: width / 2, y: height / 2 };
253
+ }
254
+
255
+ export function hotspotDisplayLabel(choice: QtiChoice, choices: QtiChoice[]): string {
256
+ return choice.attributes["hotspot-label"] || `Region ${choices.indexOf(choice) + 1}`;
257
+ }
258
+
259
+ export function hotspotAccessibleLabel(choice: QtiChoice, index: number): string {
260
+ return (
261
+ choice.attributes["aria-label"] || choice.attributes["hotspot-label"] || `Region ${index + 1}`
262
+ );
263
+ }
@@ -0,0 +1,92 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import {
3
+ interactionChoices,
4
+ missingChoicesMessage,
5
+ readableType,
6
+ responseGroup,
7
+ valueToStrings,
8
+ } from "../interaction-support.js";
9
+
10
+ function choicePresentationLabel(interaction: QtiInteraction, index: number): string {
11
+ const classNames = new Set((interaction.attributes.class ?? "").split(/\s+/).filter(Boolean));
12
+ if (classNames.has("qti-labels-none")) return "";
13
+
14
+ const labels = classNames.has("qti-labels-decimal")
15
+ ? Array.from({ length: 26 }, (_, item) => `${item + 1}`)
16
+ : classNames.has("qti-labels-lower-alpha")
17
+ ? "abcdefghijklmnopqrstuvwxyz".split("")
18
+ : "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
19
+ const suffix = classNames.has("qti-labels-suffix-none")
20
+ ? ""
21
+ : classNames.has("qti-labels-suffix-parenthesis")
22
+ ? ")"
23
+ : ".";
24
+ return `${labels[index] ?? `${index + 1}`}${suffix}`;
25
+ }
26
+
27
+ export function renderChoice(
28
+ interaction: QtiInteraction,
29
+ update: (value: QtiValue) => void,
30
+ currentValue: QtiValue,
31
+ ): HTMLElement {
32
+ const group = responseGroup("qti3-choice-group");
33
+
34
+ const multiple =
35
+ interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
36
+ const selected = new Set(valueToStrings(currentValue));
37
+ const list = document.createElement("div");
38
+ list.className = "qti3-choice-list";
39
+ list.role = "group";
40
+ list.setAttribute("aria-label", `${readableType(interaction.type)} options`);
41
+ const syncSelected = () => {
42
+ for (const label of list.querySelectorAll<HTMLElement>(".qti3-choice-option")) {
43
+ const identifier = label.dataset.choiceIdentifier ?? "";
44
+ label.dataset.selected = selected.has(identifier) ? "true" : "false";
45
+ }
46
+ };
47
+ const choices = interactionChoices(interaction);
48
+ if (choices.length === 0) {
49
+ group.append(missingChoicesMessage(interaction));
50
+ return group;
51
+ }
52
+ for (const [index, choice] of choices.entries()) {
53
+ const label = document.createElement("label");
54
+ label.className = "qti3-choice-option";
55
+ label.dataset.choiceIdentifier = choice.identifier;
56
+ const input = document.createElement("input");
57
+ input.type = multiple ? "checkbox" : "radio";
58
+ input.name = interaction.responseIdentifier ?? interaction.type;
59
+ input.value = choice.identifier;
60
+ input.checked = selected.has(choice.identifier);
61
+ input.addEventListener("change", () => {
62
+ if (multiple) {
63
+ if (input.checked) selected.add(choice.identifier);
64
+ else selected.delete(choice.identifier);
65
+ update([...selected]);
66
+ } else {
67
+ selected.clear();
68
+ selected.add(choice.identifier);
69
+ syncSelected();
70
+ update(input.value);
71
+ }
72
+ syncSelected();
73
+ });
74
+ const visibleLabel = choicePresentationLabel(interaction, index);
75
+ const optionParts: HTMLElement[] = [input];
76
+ if (visibleLabel) {
77
+ const labelText = document.createElement("span");
78
+ labelText.className = "qti3-choice-label";
79
+ labelText.textContent = visibleLabel;
80
+ optionParts.push(labelText);
81
+ }
82
+ const text = document.createElement("span");
83
+ text.className = "qti3-choice-text";
84
+ text.textContent = choice.text;
85
+ optionParts.push(text);
86
+ label.append(...optionParts);
87
+ list.append(label);
88
+ }
89
+ syncSelected();
90
+ group.append(list);
91
+ return group;
92
+ }