@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,139 @@
1
+ import type {
2
+ QtiContentNode,
3
+ QtiDiagnostic,
4
+ QtiInteraction,
5
+ QtiPortableCustomDefinition,
6
+ QtiPortableCustomStateValue,
7
+ QtiValue,
8
+ } from "@longsightgroup/qti3-core";
9
+ import type { QtiPortableCustomMountEventDetail } from "../player-types.js";
10
+ import {
11
+ portableCustomDefinitionFromAttributes,
12
+ portableCustomEventState,
13
+ portableCustomEventValidity,
14
+ portableCustomEventValue,
15
+ scalarString,
16
+ } from "../portable-custom-support.js";
17
+
18
+ export interface PortableCustomResponseContext {
19
+ interaction: QtiInteraction;
20
+ update: (value: QtiValue) => void;
21
+ currentValue: QtiValue;
22
+ currentState?: QtiPortableCustomStateValue | undefined;
23
+ renderMarkup: (nodes: QtiContentNode[]) => Node[];
24
+ setInteractionState: (responseIdentifier: string, state: QtiPortableCustomStateValue) => void;
25
+ setValidity: (responseIdentifier: string, valid: boolean, message?: string) => void;
26
+ emitStateChange: () => void;
27
+ onMount: (detail: QtiPortableCustomMountEventDetail) => void;
28
+ }
29
+
30
+ export function portableCustomValidityDiagnostic(
31
+ responseIdentifier: string,
32
+ valid: boolean,
33
+ message: string | undefined,
34
+ ): QtiDiagnostic | undefined {
35
+ if (valid) return undefined;
36
+ return {
37
+ code: "response.portableCustom.validity",
38
+ severity: "error",
39
+ message: message?.trim() || `${responseIdentifier} is not valid.`,
40
+ path: responseIdentifier,
41
+ };
42
+ }
43
+
44
+ export function renderPortableCustomResponse(context: PortableCustomResponseContext): HTMLElement {
45
+ const { interaction, update, currentValue } = context;
46
+ const definition =
47
+ interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
48
+ const responseIdentifier = interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
49
+ const currentState = context.currentState;
50
+
51
+ const group = document.createElement("div");
52
+ group.role = "group";
53
+ group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
54
+
55
+ const host = createPortableCustomHost(interaction, definition, responseIdentifier, currentState);
56
+ if (definition.interactionMarkup.length > 0) {
57
+ const markup = document.createElement("div");
58
+ markup.className = "qti3-portable-custom-markup";
59
+ markup.append(...context.renderMarkup(definition.interactionMarkup));
60
+ host.append(markup);
61
+ } else {
62
+ host.textContent = "Portable custom interaction host";
63
+ }
64
+
65
+ const fallback = document.createElement("input");
66
+ fallback.type = "hidden";
67
+ fallback.className = "qti3-portable-custom-response";
68
+ fallback.hidden = true;
69
+ fallback.tabIndex = -1;
70
+ fallback.setAttribute("aria-hidden", "true");
71
+ fallback.value = scalarString(currentValue);
72
+
73
+ const handlePortableCustomEvent = (event: Event) => {
74
+ const state = portableCustomEventState(event);
75
+ const value = portableCustomEventValue(event);
76
+ const validity = portableCustomEventValidity(event);
77
+ if (state !== undefined && responseIdentifier) {
78
+ context.setInteractionState(responseIdentifier, state);
79
+ host.dataset.state = JSON.stringify(state);
80
+ }
81
+ if (value !== undefined) {
82
+ fallback.value = scalarString(value ?? null);
83
+ update(value);
84
+ }
85
+ if (validity && responseIdentifier) {
86
+ context.setValidity(responseIdentifier, validity.valid, validity.message);
87
+ context.emitStateChange();
88
+ }
89
+ if (value === undefined && state !== undefined && !validity) context.emitStateChange();
90
+ };
91
+
92
+ for (const eventName of [
93
+ "qti3-portable-custom-response",
94
+ "qti3-pci-response",
95
+ "qti3-portable-custom-state",
96
+ "qti3-portable-custom-validity",
97
+ ] as const) {
98
+ host.addEventListener(eventName, handlePortableCustomEvent);
99
+ }
100
+
101
+ queueMicrotask(() => {
102
+ context.onMount({
103
+ responseIdentifier,
104
+ interaction,
105
+ definition,
106
+ host,
107
+ value: currentValue,
108
+ state: currentState,
109
+ });
110
+ });
111
+
112
+ group.append(host, fallback);
113
+ return group;
114
+ }
115
+
116
+ function createPortableCustomHost(
117
+ interaction: QtiInteraction,
118
+ definition: QtiPortableCustomDefinition,
119
+ responseIdentifier: string,
120
+ currentState: QtiPortableCustomStateValue | undefined,
121
+ ): HTMLElement {
122
+ const host = document.createElement("div");
123
+ host.className = "qti3-portable-custom-host";
124
+ host.tabIndex = 0;
125
+ host.dataset.responseIdentifier = responseIdentifier;
126
+ host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
127
+ host.dataset.module = definition.module ?? "";
128
+ host.dataset.qtiName = interaction.qtiName;
129
+ if (definition.interactionModules?.primaryConfiguration) {
130
+ host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
131
+ }
132
+ if (definition.interactionModules?.secondaryConfiguration) {
133
+ host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
134
+ }
135
+ if (currentState !== undefined) host.dataset.state = JSON.stringify(currentState);
136
+ host.setAttribute("role", "application");
137
+ host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
138
+ return host;
139
+ }
@@ -0,0 +1,210 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import {
3
+ applyGraphicSurfaceLayout,
4
+ applyPositionObjectMarkerPlacement,
5
+ applyPositionObjectMarkerSize,
6
+ appendGraphicObjectImage,
7
+ objectIsImage,
8
+ percent,
9
+ readableType,
10
+ responseGroup,
11
+ } from "../interaction-support.js";
12
+ import { movementButton } from "../movement.js";
13
+ import type { QtiPlayerMessages } from "../player-messages.js";
14
+ import {
15
+ objectAssetHeight,
16
+ objectAssetWidth,
17
+ parsePointValue,
18
+ pointToString,
19
+ } from "./point-value.js";
20
+
21
+ export function renderPositionObjectResponse(
22
+ interaction: QtiInteraction,
23
+ update: (value: QtiValue) => void,
24
+ currentValue: QtiValue,
25
+ messages: QtiPlayerMessages,
26
+ ): HTMLElement {
27
+ const group = responseGroup();
28
+ group.role = "group";
29
+ group.setAttribute("aria-label", `${readableType(interaction.type)} object placement response`);
30
+
31
+ const stageObject = interaction.positionObjectStage ?? interaction.object;
32
+ const movableObject = interaction.positionObjectStage ? interaction.object : undefined;
33
+ const width = objectAssetWidth(stageObject, 480);
34
+ const height = objectAssetHeight(stageObject, 300);
35
+ const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
36
+ const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
37
+ const parsedPoint = parsePointValue(currentValue);
38
+ let point = parsedPoint ?? { x: 0, y: 0 };
39
+ let isPlaced = Boolean(parsedPoint);
40
+
41
+ const stage = document.createElement("div");
42
+ applyGraphicSurfaceLayout(stage, width, height, "qti3-position-object-stage");
43
+ stage.style.setProperty("--qti3-position-object-marker-block-size", `${movableHeight}px`);
44
+ stage.tabIndex = 0;
45
+ stage.role = "group";
46
+ stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage`);
47
+
48
+ if (stageObject?.data && objectIsImage(stageObject)) {
49
+ appendGraphicObjectImage(stage, stageObject, stageObject.text || "");
50
+ }
51
+
52
+ const marker = document.createElement("button");
53
+ marker.type = "button";
54
+ marker.className = "qti3-position-object-marker";
55
+ marker.setAttribute("aria-label", messages.movableObject());
56
+ applyPositionObjectMarkerSize(marker, movableWidth, movableHeight);
57
+ marker.draggable = false;
58
+
59
+ if (movableObject?.data && objectIsImage(movableObject)) {
60
+ const image = document.createElement("img");
61
+ image.src = movableObject.data;
62
+ image.alt = "";
63
+ marker.append(image);
64
+ } else {
65
+ marker.textContent = messages.placeObject();
66
+ }
67
+ stage.append(marker);
68
+
69
+ const coordinate = document.createElement("output");
70
+ coordinate.className = "qti3-coordinate-output";
71
+ const clamp = () => {
72
+ point.x = Math.max(0, Math.min(width, point.x));
73
+ point.y = Math.max(0, Math.min(height, point.y));
74
+ };
75
+ const commit = () => {
76
+ if (!isPlaced) return;
77
+ update(pointToString(point));
78
+ };
79
+ const syncMarker = () => {
80
+ if (!isPlaced) {
81
+ marker.dataset.placed = "false";
82
+ marker.style.removeProperty("insetInlineStart");
83
+ marker.style.removeProperty("insetBlockStart");
84
+ marker.style.setProperty(
85
+ "--qti3-position-object-unplaced-inline-start",
86
+ `${Math.round(movableWidth / 2)}px`,
87
+ );
88
+ marker.style.setProperty(
89
+ "--qti3-position-object-unplaced-block-start",
90
+ `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`,
91
+ );
92
+ coordinate.value = "";
93
+ coordinate.textContent = "Object not placed";
94
+ stage.setAttribute(
95
+ "aria-label",
96
+ `${readableType(interaction.type)} placement stage, object not placed`,
97
+ );
98
+ return;
99
+ }
100
+ clamp();
101
+ marker.dataset.placed = "true";
102
+ marker.style.removeProperty("--qti3-position-object-unplaced-inline-start");
103
+ marker.style.removeProperty("--qti3-position-object-unplaced-block-start");
104
+ applyPositionObjectMarkerPlacement(
105
+ marker,
106
+ `${percent(point.x, width)}%`,
107
+ `${percent(point.y, height)}%`,
108
+ );
109
+ coordinate.value = pointToString(point);
110
+ coordinate.textContent = `Object positioned at ${pointToString(point)}`;
111
+ stage.setAttribute(
112
+ "aria-label",
113
+ `${readableType(interaction.type)} placement stage, object at ${pointToString(point)}`,
114
+ );
115
+ };
116
+ const pointFromPointer = (event: MouseEvent | PointerEvent) => {
117
+ const rect = stage.getBoundingClientRect();
118
+ point = {
119
+ x: Math.round(((event.clientX - rect.left) / rect.width) * width),
120
+ y: Math.round(((event.clientY - rect.top) / rect.height) * height),
121
+ };
122
+ isPlaced = true;
123
+ clamp();
124
+ };
125
+ const ensureKeyboardPoint = () => {
126
+ if (isPlaced) return;
127
+ point = { x: 0, y: 0 };
128
+ isPlaced = true;
129
+ };
130
+ const moveBy = (dx: number, dy: number, emit = true) => {
131
+ ensureKeyboardPoint();
132
+ point.x += dx;
133
+ point.y += dy;
134
+ syncMarker();
135
+ if (emit) commit();
136
+ };
137
+ const handleKey = (event: KeyboardEvent) => {
138
+ const step = event.shiftKey ? 10 : 1;
139
+ if (event.key === "ArrowLeft") moveBy(-step, 0, false);
140
+ else if (event.key === "ArrowRight") moveBy(step, 0, false);
141
+ else if (event.key === "ArrowUp") moveBy(0, -step, false);
142
+ else if (event.key === "ArrowDown") moveBy(0, step, false);
143
+ else if (event.key === "Enter" || event.key === " ") {
144
+ ensureKeyboardPoint();
145
+ syncMarker();
146
+ commit();
147
+ } else return;
148
+ event.preventDefault();
149
+ };
150
+
151
+ let dragging = false;
152
+ let dragMoved = false;
153
+ marker.addEventListener("pointerdown", (event) => {
154
+ dragging = true;
155
+ dragMoved = false;
156
+ marker.dataset.dragging = "true";
157
+ marker.setPointerCapture(event.pointerId);
158
+ if (isPlaced) {
159
+ pointFromPointer(event);
160
+ syncMarker();
161
+ }
162
+ event.preventDefault();
163
+ });
164
+ marker.addEventListener("pointermove", (event) => {
165
+ if (!dragging) return;
166
+ dragMoved = true;
167
+ pointFromPointer(event);
168
+ syncMarker();
169
+ });
170
+ marker.addEventListener("pointerup", (event) => {
171
+ if (!dragging) return;
172
+ dragging = false;
173
+ delete marker.dataset.dragging;
174
+ marker.releasePointerCapture(event.pointerId);
175
+ if (dragMoved || isPlaced) {
176
+ pointFromPointer(event);
177
+ syncMarker();
178
+ commit();
179
+ }
180
+ });
181
+ marker.addEventListener("pointercancel", () => {
182
+ dragging = false;
183
+ delete marker.dataset.dragging;
184
+ });
185
+ stage.addEventListener("click", (event) => {
186
+ if (event.target === marker) return;
187
+ pointFromPointer(event);
188
+ syncMarker();
189
+ commit();
190
+ });
191
+ stage.addEventListener("keydown", handleKey);
192
+ marker.addEventListener("keydown", handleKey);
193
+
194
+ const controls = document.createElement("div");
195
+ controls.className = "qti3-point-controls";
196
+ for (const [direction, dx, dy] of [
197
+ ["up", 0, -1],
198
+ ["left", -1, 0],
199
+ ["right", 1, 0],
200
+ ["down", 0, 1],
201
+ ] as const) {
202
+ controls.append(
203
+ movementButton(direction, messages.moveObject({ direction }), () => moveBy(dx, dy)),
204
+ );
205
+ }
206
+
207
+ syncMarker();
208
+ group.append(stage, coordinate, controls);
209
+ return group;
210
+ }
@@ -0,0 +1,27 @@
1
+ import type { QtiInteraction } from "@longsightgroup/qti3-core";
2
+
3
+ export function usesChoiceSet(interaction: QtiInteraction): boolean {
4
+ if (interaction.type === "choice") return true;
5
+ return (
6
+ interaction.responseCardinality === "multiple" && interaction.responseBaseType === "identifier"
7
+ );
8
+ }
9
+
10
+ export function usesOrderedResponse(interaction: QtiInteraction): boolean {
11
+ return interaction.responseCardinality === "ordered" || interaction.type === "order";
12
+ }
13
+
14
+ const explicitNonPairInteractionTypes = new Set<QtiInteraction["type"]>([
15
+ "match",
16
+ "gapMatch",
17
+ "graphicGapMatch",
18
+ "graphicAssociate",
19
+ "graphicOrder",
20
+ "order",
21
+ ]);
22
+
23
+ export function usesPairResponse(interaction: QtiInteraction): boolean {
24
+ if (interaction.type === "associate") return true;
25
+ if (explicitNonPairInteractionTypes.has(interaction.type)) return false;
26
+ return interaction.responseBaseType === "pair" || interaction.responseBaseType === "directedPair";
27
+ }
@@ -0,0 +1,185 @@
1
+ import type { QtiInteraction, QtiValue } from "@longsightgroup/qti3-core";
2
+ import {
3
+ applyGraphicSurfaceLayout,
4
+ applyPointMarkerPlacement,
5
+ appendGraphicObjectImage,
6
+ objectHeight,
7
+ objectWidth,
8
+ readableType,
9
+ responseGroup,
10
+ } from "../interaction-support.js";
11
+ import { movementButton } from "../movement.js";
12
+ import type { QtiPlayerMessages } from "../player-messages.js";
13
+ import { maximumAllowedResponses } from "../response-limits.js";
14
+ import { parsePointValues, pointToString } from "./point-value.js";
15
+
16
+ export function renderSelectPointResponse(
17
+ interaction: QtiInteraction,
18
+ update: (value: QtiValue) => void,
19
+ currentValue: QtiValue,
20
+ messages: QtiPlayerMessages,
21
+ ): HTMLElement {
22
+ const group = responseGroup();
23
+ group.role = "group";
24
+ group.setAttribute("aria-label", `${readableType(interaction.type)} coordinate response`);
25
+ const isMultiple = interaction.responseCardinality === "multiple";
26
+ const maxPoints = isMultiple ? maximumAllowedResponses(interaction) : 1;
27
+
28
+ const surface = document.createElement("button");
29
+ surface.type = "button";
30
+ applyGraphicSurfaceLayout(
31
+ surface,
32
+ objectWidth(interaction),
33
+ objectHeight(interaction),
34
+ "qti3-point-surface",
35
+ );
36
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} coordinate area`);
37
+
38
+ const object = interaction.object;
39
+ if (object) {
40
+ appendGraphicObjectImage(surface, object, "");
41
+ }
42
+
43
+ const width = objectWidth(interaction);
44
+ const height = objectHeight(interaction);
45
+ let points = parsePointValues(currentValue);
46
+ let activeIndex = points.length > 0 ? points.length - 1 : -1;
47
+ const coordinate = document.createElement("output");
48
+ coordinate.className = "qti3-coordinate-output";
49
+ const initialPoint = () => ({
50
+ x: Math.round(width / 2),
51
+ y: Math.round(height / 2),
52
+ });
53
+ const emitValue = (): QtiValue => {
54
+ const values = points.map(pointToString);
55
+ if (isMultiple) return values;
56
+ return values[0] ?? "";
57
+ };
58
+ const commit = () => {
59
+ update(emitValue());
60
+ };
61
+ const syncMarker = () => {
62
+ surface.querySelectorAll(".qti3-point-marker").forEach((marker) => marker.remove());
63
+ if (points.length === 0) {
64
+ coordinate.value = "";
65
+ coordinate.textContent = messages.noPointSelected();
66
+ surface.setAttribute("aria-label", `${readableType(interaction.type)} coordinate area`);
67
+ return;
68
+ }
69
+ points.forEach((point, index) => {
70
+ const marker = document.createElement("span");
71
+ marker.className = "qti3-point-marker";
72
+ marker.setAttribute("aria-hidden", "true");
73
+ applyPointMarkerPlacement(
74
+ marker,
75
+ `${(point.x / width) * 100}%`,
76
+ `${(point.y / height) * 100}%`,
77
+ );
78
+ if (index === activeIndex) marker.dataset.active = "true";
79
+ surface.append(marker);
80
+ });
81
+ const text = points.map(pointToString).join("; ");
82
+ coordinate.value = isMultiple
83
+ ? points.map(pointToString).join(" | ")
84
+ : pointToString(points[0]);
85
+ coordinate.textContent = isMultiple
86
+ ? `${points.length} selected point${points.length === 1 ? "" : "s"}: ${text}`
87
+ : `Selected point ${pointToString(points[0])}`;
88
+ surface.setAttribute(
89
+ "aria-label",
90
+ `${readableType(interaction.type)} coordinate area, selected ${text}`,
91
+ );
92
+ };
93
+ const clampPoint = (point: { x: number; y: number }) => {
94
+ point.x = Math.max(0, Math.min(width, point.x));
95
+ point.y = Math.max(0, Math.min(height, point.y));
96
+ };
97
+ const setActivePoint = (point: { x: number; y: number }) => {
98
+ clampPoint(point);
99
+ if (!isMultiple) {
100
+ points = [point];
101
+ activeIndex = 0;
102
+ return;
103
+ }
104
+ if (maxPoints !== undefined && points.length >= maxPoints) {
105
+ points[points.length - 1] = point;
106
+ activeIndex = points.length - 1;
107
+ return;
108
+ }
109
+ points.push(point);
110
+ activeIndex = points.length - 1;
111
+ };
112
+ const mutableActivePoint = () => {
113
+ if (points.length === 0) setActivePoint(initialPoint());
114
+ if (activeIndex < 0 || activeIndex >= points.length) activeIndex = points.length - 1;
115
+ const point = points[activeIndex];
116
+ if (point) return point;
117
+ const fallback = initialPoint();
118
+ points = [fallback];
119
+ activeIndex = 0;
120
+ return fallback;
121
+ };
122
+
123
+ surface.addEventListener("click", (event) => {
124
+ if (event.detail === 0) return;
125
+ const rect = surface.getBoundingClientRect();
126
+ setActivePoint({
127
+ x: Math.round(((event.clientX - rect.left) / rect.width) * width),
128
+ y: Math.round(((event.clientY - rect.top) / rect.height) * height),
129
+ });
130
+ syncMarker();
131
+ commit();
132
+ });
133
+ surface.addEventListener("keydown", (event) => {
134
+ const point = mutableActivePoint();
135
+ const step = event.shiftKey ? 10 : 1;
136
+ if (event.key === "ArrowLeft") point.x -= step;
137
+ else if (event.key === "ArrowRight") point.x += step;
138
+ else if (event.key === "ArrowUp") point.y -= step;
139
+ else if (event.key === "ArrowDown") point.y += step;
140
+ else if (event.key === "Enter" || event.key === " ") {
141
+ event.preventDefault();
142
+ commit();
143
+ return;
144
+ } else return;
145
+
146
+ event.preventDefault();
147
+ clampPoint(point);
148
+ syncMarker();
149
+ });
150
+
151
+ syncMarker();
152
+ const controls = document.createElement("div");
153
+ controls.className = "qti3-point-controls";
154
+ for (const [direction, dx, dy] of [
155
+ ["up", 0, -1],
156
+ ["left", -1, 0],
157
+ ["right", 1, 0],
158
+ ["down", 0, 1],
159
+ ] as const) {
160
+ controls.append(
161
+ movementButton(direction, messages.movePoint({ direction }), () => {
162
+ const point = mutableActivePoint();
163
+ point.x += dx;
164
+ point.y += dy;
165
+ clampPoint(point);
166
+ syncMarker();
167
+ commit();
168
+ }),
169
+ );
170
+ }
171
+ if (isMultiple) {
172
+ const clear = document.createElement("button");
173
+ clear.type = "button";
174
+ clear.textContent = messages.clearPoints();
175
+ clear.addEventListener("click", () => {
176
+ points = [];
177
+ activeIndex = -1;
178
+ syncMarker();
179
+ commit();
180
+ });
181
+ controls.append(clear);
182
+ }
183
+ group.append(surface, coordinate, controls);
184
+ return group;
185
+ }
@@ -0,0 +1,56 @@
1
+ import type { QtiChoice, QtiInteraction } from "@longsightgroup/qti3-core";
2
+ import { interactionChoices } from "../interaction-support.js";
3
+
4
+ export function tokenRegion(label: string, visibleLabel?: string): HTMLElement {
5
+ const region = document.createElement("div");
6
+ region.className = "qti3-token-region";
7
+ region.role = "group";
8
+ region.setAttribute("aria-label", label);
9
+ if (visibleLabel) {
10
+ const heading = document.createElement("strong");
11
+ heading.className = "qti3-region-label";
12
+ heading.textContent = visibleLabel;
13
+ region.append(heading);
14
+ }
15
+ return region;
16
+ }
17
+
18
+ export function tokenButton(choice: QtiChoice): HTMLButtonElement {
19
+ const button = document.createElement("button");
20
+ button.type = "button";
21
+ button.className = "qti3-token";
22
+ button.dataset.choiceIdentifier = choice.identifier;
23
+ button.setAttribute("aria-pressed", "false");
24
+ button.textContent = choice.text;
25
+ return button;
26
+ }
27
+
28
+ export function choiceText(choices: QtiChoice[], identifier: string | undefined): string {
29
+ if (!identifier) return "";
30
+ return choices.find((choice) => choice.identifier === identifier)?.text ?? identifier;
31
+ }
32
+
33
+ export function sourceChoices(interaction: QtiInteraction): QtiChoice[] {
34
+ const choices = interactionChoices(interaction);
35
+ if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
36
+ const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
37
+ return gapChoices.length > 0 ? gapChoices : choices;
38
+ }
39
+ const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
40
+ const sources = choices.filter((choice) => sourceRoles.has(choice.role));
41
+ return sources.length > 0 ? sources : choices;
42
+ }
43
+
44
+ export function targetChoices(interaction: QtiInteraction): QtiChoice[] {
45
+ const choices = interactionChoices(interaction);
46
+ if (interaction.type === "associate" || interaction.type === "graphicAssociate") return choices;
47
+ const targetRoles = new Set(["matchTarget", "gap", "hotspot"]);
48
+ const targets = choices.filter((choice) => targetRoles.has(choice.role));
49
+ return targets.length > 0 ? targets : choices;
50
+ }
51
+
52
+ export function pairRegionLabels(interaction: QtiInteraction): { source: string; target: string } {
53
+ if (interaction.type === "associate") return { source: "First concept", target: "Pair with" };
54
+ if (interaction.type === "match") return { source: "Prompt", target: "Match" };
55
+ return { source: "Source", target: "Target" };
56
+ }