@longsightgroup/qti3-player 0.2.0 → 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 (117) hide show
  1. package/README.md +19 -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 -114
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -4503
  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 -5053
  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 +63 -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,87 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import {
3
+ applyGraphicSurfaceLayout,
4
+ appendGraphicObjectImage,
5
+ interactionChoices,
6
+ missingChoicesMessage,
7
+ objectHeight,
8
+ objectWidth,
9
+ placeHotspotButton,
10
+ readableType,
11
+ responseGroup,
12
+ valueToStrings,
13
+ } from "../interaction-support.js";
14
+ import type { QtiPlayerMessages } from "../player-messages.js";
15
+
16
+ export function renderHotspotResponse(
17
+ interaction: QtiInteraction,
18
+ update: (value: QtiValue) => void,
19
+ currentValue: QtiValue,
20
+ messages: QtiPlayerMessages,
21
+ ): HTMLElement {
22
+ const group = responseGroup();
23
+
24
+ const surface = document.createElement("div");
25
+ const width = objectWidth(interaction);
26
+ const height = objectHeight(interaction);
27
+ applyGraphicSurfaceLayout(surface, width, height, "qti3-hotspot-surface");
28
+
29
+ const choices = interactionChoices(interaction);
30
+ if (choices.length === 0) {
31
+ group.append(missingChoicesMessage(interaction));
32
+ return group;
33
+ }
34
+
35
+ const object = interaction.object;
36
+ if (object) {
37
+ appendGraphicObjectImage(
38
+ surface,
39
+ object,
40
+ object.text || `${readableType(interaction.type)} image`,
41
+ );
42
+ }
43
+
44
+ const selected = new Set(valueToStrings(currentValue));
45
+ const multiple = interaction.responseCardinality === "multiple";
46
+ const selectedSummary = document.createElement("p");
47
+ selectedSummary.className = "qti3-selection-summary";
48
+ selectedSummary.setAttribute("aria-live", "polite");
49
+ selectedSummary.textContent = messages.noRegionSelected();
50
+ const syncSelected = () => {
51
+ for (const button of surface.querySelectorAll<HTMLButtonElement>("button")) {
52
+ const isSelected = selected.has(button.dataset.choiceIdentifier ?? "");
53
+ button.setAttribute("aria-pressed", isSelected ? "true" : "false");
54
+ button.dataset.selected = isSelected ? "true" : "false";
55
+ }
56
+ selectedSummary.textContent =
57
+ selected.size > 0 ? `Selected ${[...selected].join(", ")}` : messages.noRegionSelected();
58
+ };
59
+ for (const choice of choices) {
60
+ const button = document.createElement("button");
61
+ button.type = "button";
62
+ button.className = "qti3-hotspot-button";
63
+ button.dataset.choiceIdentifier = choice.identifier;
64
+ button.textContent = choice.text;
65
+ button.title = choice.text;
66
+ button.setAttribute("aria-pressed", "false");
67
+ placeHotspotButton(button, choice, width, height);
68
+ button.addEventListener("click", () => {
69
+ if (multiple) {
70
+ if (selected.has(choice.identifier)) selected.delete(choice.identifier);
71
+ else selected.add(choice.identifier);
72
+ syncSelected();
73
+ update([...selected]);
74
+ } else {
75
+ selected.clear();
76
+ selected.add(choice.identifier);
77
+ syncSelected();
78
+ update(choice.identifier);
79
+ }
80
+ });
81
+ surface.append(button);
82
+ }
83
+
84
+ syncSelected();
85
+ group.append(surface, selectedSummary);
86
+ return group;
87
+ }
@@ -0,0 +1,81 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import {
3
+ interactionChoices,
4
+ missingChoicesMessage,
5
+ valueToStrings,
6
+ } from "../interaction-support.js";
7
+ import { appendInlineControl, normalizeInlineSegmentText } from "./inline-controls.js";
8
+
9
+ export function renderHottextResponse(
10
+ interaction: QtiInteraction,
11
+ update: (value: QtiValue) => void,
12
+ currentValue: QtiValue,
13
+ ): HTMLElement {
14
+ const group = document.createElement("div");
15
+ group.className = "qti3-hottext-group";
16
+ group.role = "group";
17
+ group.setAttribute("aria-label", "Hottext options");
18
+
19
+ const selected = new Set(valueToStrings(currentValue));
20
+ const multiple =
21
+ interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
22
+ const passage = document.createElement("p");
23
+ passage.className = "qti3-hottext-passage";
24
+
25
+ const syncSelected = () => {
26
+ for (const button of passage.querySelectorAll<HTMLButtonElement>(".qti3-hottext-token")) {
27
+ const identifier = button.dataset.choiceIdentifier ?? "";
28
+ const isSelected = selected.has(identifier);
29
+ button.dataset.selected = isSelected ? "true" : "false";
30
+ button.setAttribute("aria-pressed", String(isSelected));
31
+ }
32
+ };
33
+
34
+ const segments =
35
+ interaction.hottextSegments && interaction.hottextSegments.length > 0
36
+ ? interaction.hottextSegments
37
+ : interactionChoices(interaction).map((choice) => ({
38
+ kind: "hottext" as const,
39
+ identifier: choice.identifier,
40
+ text: choice.text,
41
+ attributes: choice.attributes,
42
+ source: choice.source,
43
+ }));
44
+
45
+ if (segments.length === 0) {
46
+ group.append(missingChoicesMessage(interaction));
47
+ return group;
48
+ }
49
+
50
+ const content: Array<Node | string> = [];
51
+ for (const [segmentIndex, segment] of segments.entries()) {
52
+ if (segment.kind === "text") {
53
+ content.push(document.createTextNode(normalizeInlineSegmentText(segment.text)));
54
+ continue;
55
+ }
56
+
57
+ const button = document.createElement("button");
58
+ button.type = "button";
59
+ button.className = "qti3-hottext-token";
60
+ button.dataset.choiceIdentifier = segment.identifier;
61
+ button.textContent = segment.text;
62
+ button.addEventListener("click", () => {
63
+ if (multiple) {
64
+ if (selected.has(segment.identifier)) selected.delete(segment.identifier);
65
+ else selected.add(segment.identifier);
66
+ update([...selected]);
67
+ } else {
68
+ selected.clear();
69
+ selected.add(segment.identifier);
70
+ update(segment.identifier);
71
+ }
72
+ syncSelected();
73
+ });
74
+ appendInlineControl(content, button, segments[segmentIndex + 1]);
75
+ }
76
+
77
+ passage.append(...content);
78
+ syncSelected();
79
+ group.append(passage);
80
+ return group;
81
+ }
@@ -0,0 +1,45 @@
1
+ import type { QtiChoice, QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import {
3
+ interactionChoices,
4
+ missingChoicesMessage,
5
+ valueToStrings,
6
+ } from "../interaction-support.js";
7
+ import type { QtiPlayerMessages } from "../player-messages.js";
8
+ import { interactionLabel } from "./interaction-label.js";
9
+
10
+ function appendOptions(
11
+ select: HTMLSelectElement,
12
+ choices: QtiChoice[],
13
+ messages: QtiPlayerMessages,
14
+ ): void {
15
+ const empty = document.createElement("option");
16
+ empty.value = "";
17
+ empty.textContent = messages.inlineChoicePrompt();
18
+ select.append(empty);
19
+ for (const choice of choices) {
20
+ const option = document.createElement("option");
21
+ option.value = choice.identifier;
22
+ option.textContent = choice.text;
23
+ select.append(option);
24
+ }
25
+ }
26
+
27
+ export function renderSelect(
28
+ interaction: QtiInteraction,
29
+ update: (value: QtiValue) => void,
30
+ currentValue: QtiValue,
31
+ messages: QtiPlayerMessages,
32
+ ): HTMLElement {
33
+ const choices = interactionChoices(interaction);
34
+ if (choices.length === 0) return missingChoicesMessage(interaction);
35
+
36
+ const select = document.createElement("select");
37
+ select.className = "qti3-inline-select";
38
+ if (interaction.responseIdentifier) select.name = interaction.responseIdentifier;
39
+ select.setAttribute("aria-label", interactionLabel(interaction));
40
+ appendOptions(select, choices, messages);
41
+ const [selected] = valueToStrings(currentValue);
42
+ if (selected) select.value = selected;
43
+ select.addEventListener("change", () => update(select.value === "" ? null : select.value));
44
+ return select;
45
+ }
@@ -0,0 +1,21 @@
1
+ export function appendInlineControl(
2
+ content: Array<Node | string>,
3
+ control: HTMLElement,
4
+ nextSegment: { kind: string; text?: string } | undefined,
5
+ ): void {
6
+ const previous = content.at(-1);
7
+ if (previous instanceof Text && !/\s$/.test(previous.data)) {
8
+ content.push(document.createTextNode(" "));
9
+ }
10
+ content.push(control);
11
+
12
+ const nextText =
13
+ nextSegment?.kind === "text" ? normalizeInlineSegmentText(nextSegment.text) : undefined;
14
+ if (nextText && !/^\s|^[,.;:!?]/.test(nextText)) {
15
+ content.push(document.createTextNode(" "));
16
+ }
17
+ }
18
+
19
+ export function normalizeInlineSegmentText(value: string | undefined): string {
20
+ return (value ?? "").replace(/\s+([,.;:!?])/g, "$1");
21
+ }
@@ -0,0 +1,159 @@
1
+ import type {
2
+ QtiAssessmentItem,
3
+ QtiContentNode,
4
+ QtiDiagnostic,
5
+ QtiInteraction,
6
+ } from "@longsightgroup/qti3-core";
7
+ import { contentElementName } from "../content/content-dom.js";
8
+ import { interactionChoices } from "../interaction-support.js";
9
+ import { sourceChoices, targetChoices } from "./shared.js";
10
+ import { isInteractionSupported } from "./interaction-registry.js";
11
+
12
+ function diagnosticPath(interaction: QtiInteraction): string | undefined {
13
+ return interaction.responseIdentifier ?? undefined;
14
+ }
15
+
16
+ export function interactionMissingChoiceDiagnostics(interaction: QtiInteraction): QtiDiagnostic[] {
17
+ if (!interactionRequiresChoices(interaction) || interactionHasRequiredChoices(interaction)) {
18
+ return [];
19
+ }
20
+ const path = diagnosticPath(interaction);
21
+ return [
22
+ {
23
+ code: "interaction.choices.missing",
24
+ severity: "error",
25
+ message: `No choices are defined for the ${interaction.type} interaction${path ? ` (${path})` : ""}.`,
26
+ path,
27
+ },
28
+ ];
29
+ }
30
+
31
+ export function interactionUnsupportedDiagnostics(interaction: QtiInteraction): QtiDiagnostic[] {
32
+ if (isInteractionSupported(interaction)) return [];
33
+ const path = diagnosticPath(interaction);
34
+ return [
35
+ {
36
+ code: "interaction.unsupported",
37
+ severity: "error",
38
+ message: path
39
+ ? `Interaction type "${interaction.type}" (${path}) is not supported.`
40
+ : `Interaction type "${interaction.type}" is not supported.`,
41
+ path,
42
+ },
43
+ ];
44
+ }
45
+
46
+ export function collectInteractionRenderDiagnostics(
47
+ interactions: QtiInteraction[],
48
+ ): QtiDiagnostic[] {
49
+ return interactions.flatMap((interaction) => [
50
+ ...interactionUnsupportedDiagnostics(interaction),
51
+ ...interactionMissingChoiceDiagnostics(interaction),
52
+ ]);
53
+ }
54
+
55
+ const embeddableInteractionTypes = new Set<QtiInteraction["type"]>(["inlineChoice", "textEntry"]);
56
+
57
+ export function interactionEmbeddedDiagnostics(interaction: QtiInteraction): QtiDiagnostic[] {
58
+ if (embeddableInteractionTypes.has(interaction.type)) return [];
59
+ const path = diagnosticPath(interaction);
60
+ return [
61
+ {
62
+ code: "interaction.embed.unsupported",
63
+ severity: "error",
64
+ message: path
65
+ ? `Interaction type "${interaction.type}" (${path}) cannot be embedded inline in item body.`
66
+ : `Interaction type "${interaction.type}" cannot be embedded inline in item body.`,
67
+ path,
68
+ },
69
+ ];
70
+ }
71
+
72
+ export function collectEmbeddedInteractionDiagnostics(item: QtiAssessmentItem): QtiDiagnostic[] {
73
+ const embeddedIndices = findEmbeddedInteractionIndices(item.body);
74
+ return embeddedIndices.flatMap((index) => {
75
+ const interaction = item.interactions[index];
76
+ return interaction ? interactionEmbeddedDiagnostics(interaction) : [];
77
+ });
78
+ }
79
+
80
+ function findEmbeddedInteractionIndices(nodes: QtiContentNode[]): number[] {
81
+ return nodes.flatMap((node) => findEmbeddedInteractionIndicesInContext(node, false));
82
+ }
83
+
84
+ function findEmbeddedInteractionIndicesInContext(
85
+ node: QtiContentNode,
86
+ insideInlineFlow: boolean,
87
+ ): number[] {
88
+ if (node.kind === "interaction") {
89
+ return insideInlineFlow ? [node.interactionIndex] : [];
90
+ }
91
+ if ("children" in node) {
92
+ const childInsideInlineFlow = insideInlineFlow || isInlineFlowContainer(node);
93
+ return node.children.flatMap((child) =>
94
+ findEmbeddedInteractionIndicesInContext(child, childInsideInlineFlow),
95
+ );
96
+ }
97
+ return [];
98
+ }
99
+
100
+ function isInlineFlowContainer(node: QtiContentNode): boolean {
101
+ if (node.kind !== "element") return false;
102
+ if (node.qtiName === "qti-template-inline") return true;
103
+ if (node.qtiName === "p") return true;
104
+ const tag = contentElementName(node.qtiName);
105
+ if (!tag) return false;
106
+ return new Set(["span", "label", "a", "em", "strong", "b", "i", "sub", "sup", "small"]).has(tag);
107
+ }
108
+
109
+ function interactionRequiresChoices(interaction: QtiInteraction): boolean {
110
+ switch (interaction.type) {
111
+ case "choice":
112
+ case "order":
113
+ case "graphicOrder":
114
+ case "hotspot":
115
+ case "hottext":
116
+ case "inlineChoice":
117
+ case "match":
118
+ case "associate":
119
+ case "gapMatch":
120
+ case "graphicGapMatch":
121
+ case "graphicAssociate":
122
+ return true;
123
+ default:
124
+ return false;
125
+ }
126
+ }
127
+
128
+ function interactionHasRequiredChoices(interaction: QtiInteraction): boolean {
129
+ switch (interaction.type) {
130
+ case "choice":
131
+ case "inlineChoice":
132
+ return interactionChoices(interaction).length > 0;
133
+ case "order":
134
+ return interactionChoices(interaction).some((choice) => choice.role !== "gap");
135
+ case "graphicOrder":
136
+ case "hotspot":
137
+ case "graphicAssociate":
138
+ return interactionChoices(interaction).some((choice) => choice.role === "hotspot");
139
+ case "hottext":
140
+ return (
141
+ Boolean(interaction.hottextSegments?.length) || interactionChoices(interaction).length > 0
142
+ );
143
+ case "match":
144
+ case "associate":
145
+ return sourceChoices(interaction).length > 0 && targetChoices(interaction).length > 0;
146
+ case "gapMatch":
147
+ return sourceChoices(interaction).length > 0 && targetChoices(interaction).length > 0;
148
+ case "graphicGapMatch":
149
+ if (interaction.object && interaction.choices.some((choice) => choice.role === "hotspot")) {
150
+ return (
151
+ sourceChoices(interaction).length > 0 &&
152
+ targetChoices(interaction).filter((choice) => choice.role === "hotspot").length > 0
153
+ );
154
+ }
155
+ return sourceChoices(interaction).length > 0 && targetChoices(interaction).length > 0;
156
+ default:
157
+ return true;
158
+ }
159
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ interactionRegistry,
3
+ isInteractionSupported,
4
+ matchInteractionRegistryEntry,
5
+ renderInteractionResponse,
6
+ type InteractionRegistryEntry,
7
+ type InteractionRendererId,
8
+ type InteractionResponseContext,
9
+ } from "./interaction-registry.js";
@@ -0,0 +1,10 @@
1
+ import type { QtiInteraction } from "@longsightgroup/qti3-core";
2
+ import { readableType } from "../interaction-support.js";
3
+
4
+ export function interactionLabel(interaction: QtiInteraction): string {
5
+ return interaction.prompt ?? interaction.contextText ?? readableType(interaction.type);
6
+ }
7
+
8
+ export function qtiSharedClassNames(value: string | undefined): string[] {
9
+ return (value ?? "").split(/\s+/).filter((className) => className.startsWith("qti-"));
10
+ }
@@ -0,0 +1,209 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import type { QtiPlayerMessages } from "../player-messages.js";
3
+ import { renderGraphicOrderResponse } from "../reorder/graphic-order-interaction.js";
4
+ import { renderOrderedResponse } from "../reorder/order-interaction.js";
5
+ import { renderChoice } from "./choice-interaction.js";
6
+ import { renderDrawingResponse } from "./drawing-interaction.js";
7
+ import { renderEndAttemptResponse } from "./end-attempt-interaction.js";
8
+ import { renderGapMatchResponse } from "./gap-match-interaction.js";
9
+ import { renderGraphicAssociateResponse } from "./graphic-associate-interaction.js";
10
+ import { renderHotspotResponse } from "./hotspot-interaction.js";
11
+ import { renderHottextResponse } from "./hottext-interaction.js";
12
+ import { renderSelect } from "./inline-choice-interaction.js";
13
+ import { renderMatchResponse } from "./match-interaction.js";
14
+ import { renderObjectAsset } from "./object-asset.js";
15
+ import { renderPairResponse } from "./pair-interaction.js";
16
+ import { renderPositionObjectResponse } from "./position-object-interaction.js";
17
+ import { usesChoiceSet, usesOrderedResponse, usesPairResponse } from "./routing.js";
18
+ import { renderSelectPointResponse } from "./select-point-interaction.js";
19
+ import { renderSliderResponse, renderTextResponse } from "./text-interaction.js";
20
+ import { renderUnsupportedInteraction } from "./unsupported-interaction.js";
21
+ import { renderUploadResponse } from "./upload-interaction.js";
22
+
23
+ export interface InteractionResponseContext {
24
+ interaction: QtiInteraction;
25
+ update: (value: QtiValue) => void;
26
+ currentValue: QtiValue;
27
+ messages: QtiPlayerMessages;
28
+ isCompleted: () => boolean;
29
+ endAttempt: () => void;
30
+ renderPortableCustom: (
31
+ interaction: QtiInteraction,
32
+ update: (value: QtiValue) => void,
33
+ currentValue: QtiValue,
34
+ ) => HTMLElement;
35
+ }
36
+
37
+ export type InteractionRendererId =
38
+ | "graphicOrder"
39
+ | "ordered"
40
+ | "gapMatch"
41
+ | "graphicAssociate"
42
+ | "match"
43
+ | "pair"
44
+ | "hotspot"
45
+ | "hottext"
46
+ | "choice"
47
+ | "inlineChoice"
48
+ | "extendedText"
49
+ | "selectPoint"
50
+ | "positionObject"
51
+ | "drawing"
52
+ | "portableCustom"
53
+ | "textEntry"
54
+ | "slider"
55
+ | "upload"
56
+ | "endAttempt"
57
+ | "media";
58
+
59
+ type InteractionRenderer = (context: InteractionResponseContext) => HTMLElement;
60
+
61
+ export interface InteractionRegistryEntry {
62
+ id: InteractionRendererId;
63
+ matches: (interaction: QtiInteraction) => boolean;
64
+ render: InteractionRenderer;
65
+ }
66
+
67
+ export const interactionRegistry: InteractionRegistryEntry[] = [
68
+ {
69
+ id: "graphicOrder",
70
+ matches: (interaction) => interaction.type === "graphicOrder",
71
+ render: ({ interaction, update, currentValue, messages }) =>
72
+ renderGraphicOrderResponse(interaction, update, currentValue, messages),
73
+ },
74
+ {
75
+ id: "ordered",
76
+ matches: usesOrderedResponse,
77
+ render: ({ interaction, update, currentValue, messages }) =>
78
+ renderOrderedResponse(interaction, update, currentValue, messages),
79
+ },
80
+ {
81
+ id: "gapMatch",
82
+ matches: (interaction) =>
83
+ interaction.type === "gapMatch" || interaction.type === "graphicGapMatch",
84
+ render: ({ interaction, update, currentValue }) =>
85
+ renderGapMatchResponse(interaction, update, currentValue),
86
+ },
87
+ {
88
+ id: "graphicAssociate",
89
+ matches: (interaction) => interaction.type === "graphicAssociate",
90
+ render: ({ interaction, update, currentValue, messages }) =>
91
+ renderGraphicAssociateResponse(interaction, update, currentValue, messages),
92
+ },
93
+ {
94
+ id: "match",
95
+ matches: (interaction) => interaction.type === "match",
96
+ render: ({ interaction, update, currentValue, messages }) =>
97
+ renderMatchResponse(interaction, update, currentValue, messages),
98
+ },
99
+ {
100
+ id: "pair",
101
+ matches: usesPairResponse,
102
+ render: ({ interaction, update, currentValue, messages }) =>
103
+ renderPairResponse(interaction, update, currentValue, messages),
104
+ },
105
+ {
106
+ id: "hotspot",
107
+ matches: (interaction) => interaction.type === "hotspot" && Boolean(interaction.object),
108
+ render: ({ interaction, update, currentValue, messages }) =>
109
+ renderHotspotResponse(interaction, update, currentValue, messages),
110
+ },
111
+ {
112
+ id: "hottext",
113
+ matches: (interaction) => interaction.type === "hottext",
114
+ render: ({ interaction, update, currentValue }) =>
115
+ renderHottextResponse(interaction, update, currentValue),
116
+ },
117
+ {
118
+ id: "choice",
119
+ matches: usesChoiceSet,
120
+ render: ({ interaction, update, currentValue }) =>
121
+ renderChoice(interaction, update, currentValue),
122
+ },
123
+ {
124
+ id: "inlineChoice",
125
+ matches: (interaction) => interaction.type === "inlineChoice",
126
+ render: ({ interaction, update, currentValue, messages }) =>
127
+ renderSelect(interaction, update, currentValue, messages),
128
+ },
129
+ {
130
+ id: "extendedText",
131
+ matches: (interaction) => interaction.type === "extendedText",
132
+ render: ({ interaction, update, currentValue }) =>
133
+ renderTextResponse(interaction, update, "extended", currentValue),
134
+ },
135
+ {
136
+ id: "selectPoint",
137
+ matches: (interaction) => interaction.type === "selectPoint",
138
+ render: ({ interaction, update, currentValue, messages }) =>
139
+ renderSelectPointResponse(interaction, update, currentValue, messages),
140
+ },
141
+ {
142
+ id: "positionObject",
143
+ matches: (interaction) => interaction.type === "positionObject",
144
+ render: ({ interaction, update, currentValue, messages }) =>
145
+ renderPositionObjectResponse(interaction, update, currentValue, messages),
146
+ },
147
+ {
148
+ id: "drawing",
149
+ matches: (interaction) => interaction.type === "drawing",
150
+ render: ({ interaction, update, currentValue, messages }) =>
151
+ renderDrawingResponse(interaction, update, currentValue, messages),
152
+ },
153
+ {
154
+ id: "portableCustom",
155
+ matches: (interaction) => interaction.type === "portableCustom",
156
+ render: ({ interaction, update, currentValue, renderPortableCustom }) =>
157
+ renderPortableCustom(interaction, update, currentValue),
158
+ },
159
+ {
160
+ id: "textEntry",
161
+ matches: (interaction) => interaction.type === "textEntry",
162
+ render: ({ interaction, update, currentValue }) =>
163
+ renderTextResponse(interaction, update, "entry", currentValue),
164
+ },
165
+ {
166
+ id: "slider",
167
+ matches: (interaction) => interaction.type === "slider",
168
+ render: ({ interaction, update, currentValue }) =>
169
+ renderSliderResponse(interaction, update, currentValue),
170
+ },
171
+ {
172
+ id: "upload",
173
+ matches: (interaction) => interaction.type === "upload",
174
+ render: ({ interaction, update, messages }) =>
175
+ renderUploadResponse(interaction, update, messages),
176
+ },
177
+ {
178
+ id: "endAttempt",
179
+ matches: (interaction) => interaction.type === "endAttempt",
180
+ render: ({ interaction, update, endAttempt, messages }) =>
181
+ renderEndAttemptResponse(interaction, update, endAttempt, messages),
182
+ },
183
+ {
184
+ id: "media",
185
+ matches: (interaction) => interaction.type === "media",
186
+ render: ({ interaction, update, currentValue, isCompleted }) =>
187
+ renderObjectAsset(interaction, {
188
+ currentValue,
189
+ update,
190
+ isCompleted,
191
+ }),
192
+ },
193
+ ];
194
+
195
+ export function matchInteractionRegistryEntry(
196
+ interaction: QtiInteraction,
197
+ ): InteractionRegistryEntry | undefined {
198
+ return interactionRegistry.find((entry) => entry.matches(interaction));
199
+ }
200
+
201
+ export function isInteractionSupported(interaction: QtiInteraction): boolean {
202
+ return matchInteractionRegistryEntry(interaction) !== undefined;
203
+ }
204
+
205
+ export function renderInteractionResponse(context: InteractionResponseContext): HTMLElement {
206
+ const entry = matchInteractionRegistryEntry(context.interaction);
207
+ if (entry) return entry.render(context);
208
+ return renderUnsupportedInteraction(context.interaction);
209
+ }