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