@longsightgroup/qti3-player 0.1.2 → 0.2.1
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/README.md +29 -0
- package/dist/index.d.ts +35 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +898 -104
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +1018 -107
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { assertQtiAttemptStateV1, createItemSession, parseQtiXml, visibleModalFeedback, } from "@longsightgroup/qti3-core";
|
|
1
|
+
import { assertQtiAttemptStateV1, createItemSession, createCatalogSupportResolution, createTextToSpeechTraversal, parseQtiXml, visibleModalFeedback, } from "@longsightgroup/qti3-core";
|
|
2
2
|
const HTMLElementBase = globalThis.HTMLElement ??
|
|
3
3
|
class {
|
|
4
4
|
replaceChildren() { }
|
|
@@ -7,14 +7,48 @@ const HTMLElementBase = globalThis.HTMLElement ??
|
|
|
7
7
|
}
|
|
8
8
|
};
|
|
9
9
|
export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
10
|
+
static get observedAttributes() {
|
|
11
|
+
return ["language-of-interface", "locale"];
|
|
12
|
+
}
|
|
10
13
|
documentModel;
|
|
11
14
|
session;
|
|
12
15
|
resolveAsset;
|
|
13
16
|
validationMessages = [];
|
|
17
|
+
languageOfInterfaceOverride;
|
|
18
|
+
messageOverrides = {};
|
|
14
19
|
sessionControl = {
|
|
15
20
|
validateResponses: true,
|
|
16
21
|
showFeedback: true,
|
|
17
22
|
};
|
|
23
|
+
get languageOfInterface() {
|
|
24
|
+
return (this.languageOfInterfaceOverride ??
|
|
25
|
+
this.getAttribute?.("language-of-interface") ??
|
|
26
|
+
this.getAttribute?.("locale") ??
|
|
27
|
+
defaultPlayerLocale(this));
|
|
28
|
+
}
|
|
29
|
+
set languageOfInterface(value) {
|
|
30
|
+
this.languageOfInterfaceOverride = normalizedLocale(value);
|
|
31
|
+
this.rerenderIfLoaded();
|
|
32
|
+
}
|
|
33
|
+
get locale() {
|
|
34
|
+
return this.languageOfInterface;
|
|
35
|
+
}
|
|
36
|
+
set locale(value) {
|
|
37
|
+
this.languageOfInterface = value;
|
|
38
|
+
}
|
|
39
|
+
get messages() {
|
|
40
|
+
return this.messageOverrides;
|
|
41
|
+
}
|
|
42
|
+
set messages(value) {
|
|
43
|
+
this.messageOverrides = value ?? {};
|
|
44
|
+
this.rerenderIfLoaded();
|
|
45
|
+
}
|
|
46
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
47
|
+
if ((name !== "language-of-interface" && name !== "locale") || oldValue === newValue) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
this.rerenderIfLoaded();
|
|
51
|
+
}
|
|
18
52
|
async loadXml(xml, options = {}) {
|
|
19
53
|
this.sessionControl = {
|
|
20
54
|
validateResponses: options.sessionControl?.validateResponses ?? true,
|
|
@@ -129,6 +163,16 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
129
163
|
state.validationMessages = cloneDiagnostics(this.validationMessages);
|
|
130
164
|
return state;
|
|
131
165
|
}
|
|
166
|
+
getTextToSpeechTraversal() {
|
|
167
|
+
if (!this.documentModel)
|
|
168
|
+
return undefined;
|
|
169
|
+
return createTextToSpeechTraversal(this.documentModel);
|
|
170
|
+
}
|
|
171
|
+
getCatalogSupportResolution(options = {}) {
|
|
172
|
+
if (!this.documentModel)
|
|
173
|
+
return undefined;
|
|
174
|
+
return createCatalogSupportResolution(this.documentModel, options);
|
|
175
|
+
}
|
|
132
176
|
emitStateChange(state = this.serialize()) {
|
|
133
177
|
if (!state)
|
|
134
178
|
return;
|
|
@@ -137,6 +181,16 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
137
181
|
dispatchPlayerEvent(type, detail) {
|
|
138
182
|
this.dispatchEvent(new CustomEvent(type, { detail }));
|
|
139
183
|
}
|
|
184
|
+
playerMessages() {
|
|
185
|
+
return resolvePlayerMessages(this.languageOfInterface, this.messageOverrides);
|
|
186
|
+
}
|
|
187
|
+
rerenderIfLoaded() {
|
|
188
|
+
if (!this.documentModel)
|
|
189
|
+
return;
|
|
190
|
+
this.render();
|
|
191
|
+
this.renderValidationMessages();
|
|
192
|
+
this.updateAttemptAvailability();
|
|
193
|
+
}
|
|
140
194
|
render() {
|
|
141
195
|
const documentModel = this.documentModel;
|
|
142
196
|
if (!documentModel)
|
|
@@ -144,6 +198,10 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
144
198
|
this.applyDefaultStyles();
|
|
145
199
|
const root = document.createElement("article");
|
|
146
200
|
root.className = "qti3-player";
|
|
201
|
+
if (documentModel.item.language) {
|
|
202
|
+
root.lang = documentModel.item.language;
|
|
203
|
+
root.setAttribute("xml:lang", documentModel.item.language);
|
|
204
|
+
}
|
|
147
205
|
root.append(playerStyleElement());
|
|
148
206
|
if (documentModel.item.prompt && documentModel.item.body.length === 0) {
|
|
149
207
|
const prompt = document.createElement("p");
|
|
@@ -172,6 +230,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
172
230
|
this.replaceChildren(root);
|
|
173
231
|
}
|
|
174
232
|
renderInteraction(interaction) {
|
|
233
|
+
const messages = this.playerMessages();
|
|
175
234
|
const field = document.createElement("section");
|
|
176
235
|
field.className = `qti3-interaction qti3-${interaction.type}`;
|
|
177
236
|
field.classList.add(...qtiSharedClassNames(interaction.attributes.class));
|
|
@@ -179,6 +238,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
179
238
|
if (interaction.responseIdentifier)
|
|
180
239
|
field.dataset.responseIdentifier = interaction.responseIdentifier;
|
|
181
240
|
const heading = document.createElement("h3");
|
|
241
|
+
copySafeAttributes(heading, interaction.promptAttributes ?? {});
|
|
182
242
|
heading.textContent = interactionLabel(interaction);
|
|
183
243
|
field.append(heading);
|
|
184
244
|
if (interaction.responseIdentifier) {
|
|
@@ -197,7 +257,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
197
257
|
};
|
|
198
258
|
const currentValue = responseIdentifier ? this.currentResponseValue(responseIdentifier) : null;
|
|
199
259
|
if (interaction.type === "graphicOrder") {
|
|
200
|
-
field.append(renderGraphicOrderResponse(interaction, update, currentValue));
|
|
260
|
+
field.append(renderGraphicOrderResponse(interaction, update, currentValue, messages));
|
|
201
261
|
return field;
|
|
202
262
|
}
|
|
203
263
|
if (usesOrderedResponse(interaction)) {
|
|
@@ -209,15 +269,15 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
209
269
|
return field;
|
|
210
270
|
}
|
|
211
271
|
if (interaction.type === "graphicAssociate") {
|
|
212
|
-
field.append(renderGraphicAssociateResponse(interaction, update, currentValue));
|
|
272
|
+
field.append(renderGraphicAssociateResponse(interaction, update, currentValue, messages));
|
|
213
273
|
return field;
|
|
214
274
|
}
|
|
215
275
|
if (interaction.type === "match") {
|
|
216
|
-
field.append(renderMatchResponse(interaction, update, currentValue));
|
|
276
|
+
field.append(renderMatchResponse(interaction, update, currentValue, messages));
|
|
217
277
|
return field;
|
|
218
278
|
}
|
|
219
279
|
if (usesPairResponse(interaction)) {
|
|
220
|
-
field.append(renderPairResponse(interaction, update, currentValue));
|
|
280
|
+
field.append(renderPairResponse(interaction, update, currentValue, messages));
|
|
221
281
|
return field;
|
|
222
282
|
}
|
|
223
283
|
if (interaction.type === "hotspot" && interaction.object) {
|
|
@@ -253,7 +313,7 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
253
313
|
return field;
|
|
254
314
|
}
|
|
255
315
|
if (interaction.type === "portableCustom") {
|
|
256
|
-
field.append(renderPortableCustomResponse(interaction, update, currentValue));
|
|
316
|
+
field.append(this.renderPortableCustomResponse(interaction, update, currentValue));
|
|
257
317
|
return field;
|
|
258
318
|
}
|
|
259
319
|
if (interaction.type === "textEntry") {
|
|
@@ -295,6 +355,87 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
295
355
|
field.append(renderSelect(interaction, update, currentValue));
|
|
296
356
|
return field;
|
|
297
357
|
}
|
|
358
|
+
renderPortableCustomResponse(interaction, update, currentValue) {
|
|
359
|
+
const definition = interaction.portableCustom ?? portableCustomDefinitionFromAttributes(interaction);
|
|
360
|
+
const responseIdentifier = interaction.responseIdentifier ?? definition.responseIdentifier ?? "";
|
|
361
|
+
const currentState = responseIdentifier
|
|
362
|
+
? this.currentInteractionState(responseIdentifier)
|
|
363
|
+
: undefined;
|
|
364
|
+
const group = document.createElement("div");
|
|
365
|
+
group.role = "group";
|
|
366
|
+
group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
|
|
367
|
+
const host = document.createElement("div");
|
|
368
|
+
host.className = "qti3-portable-custom-host";
|
|
369
|
+
host.tabIndex = 0;
|
|
370
|
+
host.dataset.responseIdentifier = responseIdentifier;
|
|
371
|
+
host.dataset.typeIdentifier = definition.customInteractionTypeIdentifier ?? "";
|
|
372
|
+
host.dataset.module = definition.module ?? "";
|
|
373
|
+
host.dataset.qtiName = interaction.qtiName;
|
|
374
|
+
if (definition.interactionModules?.primaryConfiguration) {
|
|
375
|
+
host.dataset.primaryConfiguration = definition.interactionModules.primaryConfiguration;
|
|
376
|
+
}
|
|
377
|
+
if (definition.interactionModules?.secondaryConfiguration) {
|
|
378
|
+
host.dataset.secondaryConfiguration = definition.interactionModules.secondaryConfiguration;
|
|
379
|
+
}
|
|
380
|
+
if (currentState !== undefined)
|
|
381
|
+
host.dataset.state = JSON.stringify(currentState);
|
|
382
|
+
host.setAttribute("role", "application");
|
|
383
|
+
host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
|
|
384
|
+
host.style.border = "1px solid CanvasText";
|
|
385
|
+
host.style.padding = "0.5rem";
|
|
386
|
+
host.style.marginBlockEnd = "0.5rem";
|
|
387
|
+
if (definition.interactionMarkup.length > 0) {
|
|
388
|
+
const markup = document.createElement("div");
|
|
389
|
+
markup.className = "qti3-portable-custom-markup";
|
|
390
|
+
markup.append(...this.renderContentNodes(definition.interactionMarkup));
|
|
391
|
+
host.append(markup);
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
host.textContent = "Portable custom interaction host";
|
|
395
|
+
}
|
|
396
|
+
const fallback = document.createElement("input");
|
|
397
|
+
fallback.type = "hidden";
|
|
398
|
+
fallback.className = "qti3-portable-custom-response";
|
|
399
|
+
fallback.hidden = true;
|
|
400
|
+
fallback.tabIndex = -1;
|
|
401
|
+
fallback.setAttribute("aria-hidden", "true");
|
|
402
|
+
fallback.value = scalarString(currentValue);
|
|
403
|
+
const handlePortableCustomEvent = (event) => {
|
|
404
|
+
const state = portableCustomEventState(event);
|
|
405
|
+
const value = portableCustomEventValue(event);
|
|
406
|
+
const validity = portableCustomEventValidity(event);
|
|
407
|
+
if (state !== undefined && responseIdentifier && this.session) {
|
|
408
|
+
this.session.setInteractionState(responseIdentifier, state);
|
|
409
|
+
host.dataset.state = JSON.stringify(state);
|
|
410
|
+
}
|
|
411
|
+
if (value !== undefined) {
|
|
412
|
+
fallback.value = String(value ?? "");
|
|
413
|
+
update(value);
|
|
414
|
+
}
|
|
415
|
+
if (validity && responseIdentifier) {
|
|
416
|
+
this.setPortableCustomValidity(responseIdentifier, validity.valid, validity.message);
|
|
417
|
+
this.emitStateChange();
|
|
418
|
+
}
|
|
419
|
+
if (value === undefined && state !== undefined && !validity)
|
|
420
|
+
this.emitStateChange();
|
|
421
|
+
};
|
|
422
|
+
host.addEventListener("qti3-portable-custom-response", handlePortableCustomEvent);
|
|
423
|
+
host.addEventListener("qti3-pci-response", handlePortableCustomEvent);
|
|
424
|
+
host.addEventListener("qti3-portable-custom-state", handlePortableCustomEvent);
|
|
425
|
+
host.addEventListener("qti3-portable-custom-validity", handlePortableCustomEvent);
|
|
426
|
+
queueMicrotask(() => {
|
|
427
|
+
this.dispatchPlayerEvent("qti-portable-custom-mount", {
|
|
428
|
+
responseIdentifier,
|
|
429
|
+
interaction,
|
|
430
|
+
definition,
|
|
431
|
+
host,
|
|
432
|
+
value: currentValue,
|
|
433
|
+
state: currentState,
|
|
434
|
+
});
|
|
435
|
+
});
|
|
436
|
+
group.append(host, fallback);
|
|
437
|
+
return group;
|
|
438
|
+
}
|
|
298
439
|
renderEmbeddedInteraction(interaction) {
|
|
299
440
|
if (interaction.type !== "inlineChoice" && interaction.type !== "textEntry") {
|
|
300
441
|
return this.renderInteraction(interaction);
|
|
@@ -346,10 +487,13 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
346
487
|
}
|
|
347
488
|
if (node.qtiName === "qti-prompt") {
|
|
348
489
|
const prompt = document.createElement("p");
|
|
349
|
-
prompt.
|
|
490
|
+
copySafeAttributes(prompt, node.attributes);
|
|
491
|
+
prompt.classList.add("qti3-item-prompt");
|
|
350
492
|
prompt.append(...this.renderContentNodes(node.children));
|
|
351
493
|
return [prompt];
|
|
352
494
|
}
|
|
495
|
+
if (unsafeContentElements.has(node.qtiName))
|
|
496
|
+
return [];
|
|
353
497
|
const elementName = contentElementName(node.qtiName);
|
|
354
498
|
if (!elementName)
|
|
355
499
|
return this.renderContentNodes(node.children);
|
|
@@ -489,6 +633,26 @@ export class QtiAssessmentItemPlayer extends HTMLElementBase {
|
|
|
489
633
|
currentResponseValue(identifier) {
|
|
490
634
|
return this.session?.serialize().responses[identifier] ?? null;
|
|
491
635
|
}
|
|
636
|
+
currentInteractionState(identifier) {
|
|
637
|
+
return this.session?.serialize().interactionStates?.[identifier];
|
|
638
|
+
}
|
|
639
|
+
setPortableCustomValidity(responseIdentifier, valid, message) {
|
|
640
|
+
if (valid) {
|
|
641
|
+
this.clearValidationMessage(responseIdentifier);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
const diagnostic = {
|
|
645
|
+
code: "response.portableCustom.validity",
|
|
646
|
+
severity: "error",
|
|
647
|
+
message: message?.trim() || `${responseIdentifier} is not valid.`,
|
|
648
|
+
path: responseIdentifier,
|
|
649
|
+
};
|
|
650
|
+
this.validationMessages = [
|
|
651
|
+
...this.validationMessages.filter((entry) => entry.path !== responseIdentifier),
|
|
652
|
+
diagnostic,
|
|
653
|
+
];
|
|
654
|
+
this.renderValidationMessages();
|
|
655
|
+
}
|
|
492
656
|
applyDefaultStyles() {
|
|
493
657
|
this.style.color = "CanvasText";
|
|
494
658
|
this.style.backgroundColor = "Canvas";
|
|
@@ -608,6 +772,137 @@ export function defineQtiAssessmentItemPlayer() {
|
|
|
608
772
|
customElements.define("qti-assessment-item-player", QtiAssessmentItemPlayer);
|
|
609
773
|
}
|
|
610
774
|
}
|
|
775
|
+
const defaultEnglishPlayerMessages = {
|
|
776
|
+
remove: () => "Remove",
|
|
777
|
+
removePair: ({ label }) => `Remove ${label}`,
|
|
778
|
+
};
|
|
779
|
+
const playerMessages = {
|
|
780
|
+
defaultEnglish: defaultEnglishPlayerMessages,
|
|
781
|
+
spanish: playerMessageCatalog("Quitar", ({ label }) => `Quitar ${label}`),
|
|
782
|
+
swedish: playerMessageCatalog("Ta bort", ({ label }) => `Ta bort ${label}`),
|
|
783
|
+
german: playerMessageCatalog("Entfernen", ({ label }) => `${label} entfernen`),
|
|
784
|
+
portuguese: playerMessageCatalog("Remover", ({ label }) => `Remover ${label}`),
|
|
785
|
+
french: playerMessageCatalog("Supprimer", ({ label }) => `Supprimer ${label}`),
|
|
786
|
+
};
|
|
787
|
+
const builtInPlayerMessageCatalogs = new Map([
|
|
788
|
+
["en", playerMessages.defaultEnglish],
|
|
789
|
+
["es", playerMessages.spanish],
|
|
790
|
+
["es-es", playerMessages.spanish],
|
|
791
|
+
["es-mx", playerMessages.spanish],
|
|
792
|
+
["sv", playerMessages.swedish],
|
|
793
|
+
["sv-se", playerMessages.swedish],
|
|
794
|
+
["de", playerMessages.german],
|
|
795
|
+
["de-de", playerMessages.german],
|
|
796
|
+
["pt", playerMessages.portuguese],
|
|
797
|
+
["pt-br", playerMessages.portuguese],
|
|
798
|
+
["pt-pt", playerMessages.portuguese],
|
|
799
|
+
["fr", playerMessages.french],
|
|
800
|
+
["fr-ca", playerMessages.french],
|
|
801
|
+
["fr-fr", playerMessages.french],
|
|
802
|
+
]);
|
|
803
|
+
function playerMessageCatalog(remove, removePair) {
|
|
804
|
+
return {
|
|
805
|
+
remove: () => remove,
|
|
806
|
+
removePair,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
function resolvePlayerMessages(locale, overrides) {
|
|
810
|
+
const catalog = builtInPlayerMessageCatalog(locale);
|
|
811
|
+
return {
|
|
812
|
+
remove: overrides.remove ?? catalog?.remove ?? defaultEnglishPlayerMessages.remove,
|
|
813
|
+
removePair: overrides.removePair ?? catalog?.removePair ?? defaultEnglishPlayerMessages.removePair,
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
function builtInPlayerMessageCatalog(locale) {
|
|
817
|
+
for (const candidate of localeFallbacks(locale)) {
|
|
818
|
+
const catalog = builtInPlayerMessageCatalogs.get(candidate);
|
|
819
|
+
if (catalog)
|
|
820
|
+
return catalog;
|
|
821
|
+
}
|
|
822
|
+
return undefined;
|
|
823
|
+
}
|
|
824
|
+
function localeFallbacks(locale) {
|
|
825
|
+
const normalized = normalizedLocale(locale)?.toLowerCase();
|
|
826
|
+
if (!normalized)
|
|
827
|
+
return ["en"];
|
|
828
|
+
const parts = normalized.split("-");
|
|
829
|
+
const fallbacks = [];
|
|
830
|
+
for (let length = parts.length; length > 0; length -= 1) {
|
|
831
|
+
fallbacks.push(parts.slice(0, length).join("-"));
|
|
832
|
+
}
|
|
833
|
+
return fallbacks.includes("en") ? fallbacks : [...fallbacks, "en"];
|
|
834
|
+
}
|
|
835
|
+
function normalizedLocale(value) {
|
|
836
|
+
const trimmed = value?.trim();
|
|
837
|
+
if (!trimmed)
|
|
838
|
+
return undefined;
|
|
839
|
+
try {
|
|
840
|
+
return Intl.getCanonicalLocales(trimmed)[0] ?? trimmed;
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return trimmed;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
function defaultPlayerLocale(host) {
|
|
847
|
+
const elementLanguage = normalizedLocale(host?.getAttribute("lang"));
|
|
848
|
+
if (elementLanguage)
|
|
849
|
+
return elementLanguage;
|
|
850
|
+
const navigatorLanguages = globalThis.navigator?.languages ?? [];
|
|
851
|
+
for (const language of navigatorLanguages) {
|
|
852
|
+
const normalized = normalizedLocale(language);
|
|
853
|
+
if (normalized)
|
|
854
|
+
return normalized;
|
|
855
|
+
}
|
|
856
|
+
return (normalizedLocale(globalThis.navigator?.language) ??
|
|
857
|
+
normalizedLocale(host?.closest("[lang]")?.getAttribute("lang")) ??
|
|
858
|
+
normalizedLocale(host?.ownerDocument?.documentElement.lang) ??
|
|
859
|
+
normalizedLocale(globalThis.document?.documentElement.lang) ??
|
|
860
|
+
"en");
|
|
861
|
+
}
|
|
862
|
+
function removeButton(label, messages) {
|
|
863
|
+
const safeLabel = label?.trim() || messages.remove();
|
|
864
|
+
const button = document.createElement("button");
|
|
865
|
+
button.type = "button";
|
|
866
|
+
button.className = "qti3-icon-button qti3-remove-button";
|
|
867
|
+
button.title = messages.remove();
|
|
868
|
+
button.setAttribute("aria-label", messages.removePair({ label: safeLabel }));
|
|
869
|
+
button.append(trashIcon());
|
|
870
|
+
return button;
|
|
871
|
+
}
|
|
872
|
+
function inlineIcon(className, paths) {
|
|
873
|
+
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
874
|
+
svg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
|
875
|
+
svg.setAttribute("width", "24");
|
|
876
|
+
svg.setAttribute("height", "24");
|
|
877
|
+
svg.setAttribute("viewBox", "0 0 24 24");
|
|
878
|
+
svg.setAttribute("fill", "none");
|
|
879
|
+
svg.setAttribute("stroke", "currentColor");
|
|
880
|
+
svg.setAttribute("stroke-width", "2");
|
|
881
|
+
svg.setAttribute("stroke-linecap", "round");
|
|
882
|
+
svg.setAttribute("stroke-linejoin", "round");
|
|
883
|
+
svg.setAttribute("aria-hidden", "true");
|
|
884
|
+
svg.setAttribute("focusable", "false");
|
|
885
|
+
svg.setAttribute("class", className);
|
|
886
|
+
for (const entry of paths) {
|
|
887
|
+
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
888
|
+
if (typeof entry === "string") {
|
|
889
|
+
path.setAttribute("d", entry);
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
path.setAttribute("d", entry.d);
|
|
893
|
+
if (entry.stroke) {
|
|
894
|
+
path.setAttribute("stroke", entry.stroke);
|
|
895
|
+
path.style.stroke = entry.stroke;
|
|
896
|
+
}
|
|
897
|
+
if (entry.fill) {
|
|
898
|
+
path.setAttribute("fill", entry.fill);
|
|
899
|
+
path.style.fill = entry.fill;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
svg.append(path);
|
|
903
|
+
}
|
|
904
|
+
return svg;
|
|
905
|
+
}
|
|
611
906
|
function renderChoice(interaction, update, currentValue) {
|
|
612
907
|
const group = responseGroup("qti3-choice-group");
|
|
613
908
|
const multiple = interaction.responseCardinality === "multiple" || interaction.responseCardinality === "ordered";
|
|
@@ -671,19 +966,32 @@ function responseGroup(className) {
|
|
|
671
966
|
group.className = ["qti3-response-group", className].filter(Boolean).join(" ");
|
|
672
967
|
return group;
|
|
673
968
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
969
|
+
function trashIcon() {
|
|
970
|
+
return inlineIcon("qti3-trash-icon", [
|
|
971
|
+
{ d: "M0 0h24v24H0z", stroke: "none", fill: "none" },
|
|
972
|
+
"M4 7l16 0",
|
|
973
|
+
"M10 11l0 6",
|
|
974
|
+
"M14 11l0 6",
|
|
975
|
+
"M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12",
|
|
976
|
+
"M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3",
|
|
977
|
+
]);
|
|
978
|
+
}
|
|
979
|
+
const movementIconPaths = {
|
|
980
|
+
up: ["M12 5l0 14", "M18 11l-6 -6", "M6 11l6 -6"],
|
|
981
|
+
down: ["M12 5l0 14", "M18 13l-6 6", "M6 13l6 6"],
|
|
982
|
+
left: ["M5 12l14 0", "M5 12l6 6", "M5 12l6 -6"],
|
|
983
|
+
right: ["M5 12l14 0", "M13 18l6 -6", "M13 6l6 6"],
|
|
679
984
|
};
|
|
985
|
+
function movementIcon(direction) {
|
|
986
|
+
return inlineIcon("qti3-movement-icon", movementIconPaths[direction]);
|
|
987
|
+
}
|
|
680
988
|
function movementButton(direction, accessibleName, onClick) {
|
|
681
989
|
const button = document.createElement("button");
|
|
682
990
|
button.type = "button";
|
|
683
|
-
button.className = "qti3-icon-button";
|
|
991
|
+
button.className = "qti3-icon-button qti3-move-button";
|
|
684
992
|
button.dataset.moveDirection = direction;
|
|
685
|
-
button.textContent = movementGlyphs[direction];
|
|
686
993
|
button.setAttribute("aria-label", accessibleName);
|
|
994
|
+
button.append(movementIcon(direction));
|
|
687
995
|
button.addEventListener("click", onClick);
|
|
688
996
|
return button;
|
|
689
997
|
}
|
|
@@ -888,7 +1196,7 @@ function renderOrderedResponse(interaction, update, currentValue) {
|
|
|
888
1196
|
group.append(list);
|
|
889
1197
|
return group;
|
|
890
1198
|
}
|
|
891
|
-
function renderPairResponse(interaction, update, currentValue) {
|
|
1199
|
+
function renderPairResponse(interaction, update, currentValue, messages) {
|
|
892
1200
|
const group = responseGroup();
|
|
893
1201
|
appendGraphicContext(group, interaction);
|
|
894
1202
|
const sources = sourceChoices(interaction);
|
|
@@ -947,10 +1255,7 @@ function renderPairResponse(interaction, update, currentValue) {
|
|
|
947
1255
|
item.className = "qti3-pair-chip";
|
|
948
1256
|
const text = document.createElement("span");
|
|
949
1257
|
text.textContent = `${choiceText(sources, source)} to ${choiceText(targets, target)}`;
|
|
950
|
-
const remove =
|
|
951
|
-
remove.type = "button";
|
|
952
|
-
remove.textContent = "Remove";
|
|
953
|
-
remove.setAttribute("aria-label", `Remove ${text.textContent}`);
|
|
1258
|
+
const remove = removeButton(text.textContent, messages);
|
|
954
1259
|
remove.addEventListener("click", () => {
|
|
955
1260
|
const index = selectedPairs.indexOf(pair);
|
|
956
1261
|
if (index >= 0)
|
|
@@ -1005,7 +1310,7 @@ function renderPairResponse(interaction, update, currentValue) {
|
|
|
1005
1310
|
group.append(selector, pairList);
|
|
1006
1311
|
return group;
|
|
1007
1312
|
}
|
|
1008
|
-
function renderMatchResponse(interaction, update, currentValue) {
|
|
1313
|
+
function renderMatchResponse(interaction, update, currentValue, messages) {
|
|
1009
1314
|
const group = responseGroup();
|
|
1010
1315
|
const sources = sourceChoices(interaction);
|
|
1011
1316
|
const targets = targetChoices(interaction);
|
|
@@ -1057,10 +1362,7 @@ function renderMatchResponse(interaction, update, currentValue) {
|
|
|
1057
1362
|
item.className = "qti3-pair-chip";
|
|
1058
1363
|
const text = document.createElement("span");
|
|
1059
1364
|
text.textContent = label;
|
|
1060
|
-
const remove =
|
|
1061
|
-
remove.type = "button";
|
|
1062
|
-
remove.textContent = "Remove";
|
|
1063
|
-
remove.setAttribute("aria-label", `Remove ${label}`);
|
|
1365
|
+
const remove = removeButton(label, messages);
|
|
1064
1366
|
remove.addEventListener("click", () => {
|
|
1065
1367
|
removePair(pair);
|
|
1066
1368
|
syncPressed();
|
|
@@ -1191,7 +1493,7 @@ function pairRegionLabels(interaction) {
|
|
|
1191
1493
|
return { source: "Prompt", target: "Match" };
|
|
1192
1494
|
return { source: "Source", target: "Target" };
|
|
1193
1495
|
}
|
|
1194
|
-
function renderGraphicOrderResponse(interaction, update, currentValue) {
|
|
1496
|
+
function renderGraphicOrderResponse(interaction, update, currentValue, messages) {
|
|
1195
1497
|
const group = responseGroup();
|
|
1196
1498
|
const width = objectWidth(interaction);
|
|
1197
1499
|
const height = objectHeight(interaction);
|
|
@@ -1352,10 +1654,7 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
|
|
|
1352
1654
|
up.disabled = index === 0;
|
|
1353
1655
|
const down = movementButton("down", movementLabel(choiceLabel, "down"), () => moveHotspot(choice.identifier, 1));
|
|
1354
1656
|
down.disabled = index === currentChoices.length - 1;
|
|
1355
|
-
const remove =
|
|
1356
|
-
remove.type = "button";
|
|
1357
|
-
remove.textContent = "Remove";
|
|
1358
|
-
remove.setAttribute("aria-label", `Remove ${choiceLabel}`);
|
|
1657
|
+
const remove = removeButton(choiceLabel, messages);
|
|
1359
1658
|
remove.addEventListener("click", () => removeHotspot(choice.identifier));
|
|
1360
1659
|
item.append(label, up, down, remove);
|
|
1361
1660
|
return item;
|
|
@@ -1399,7 +1698,7 @@ function renderGraphicOrderResponse(interaction, update, currentValue) {
|
|
|
1399
1698
|
group.append(surface, summary, list);
|
|
1400
1699
|
return group;
|
|
1401
1700
|
}
|
|
1402
|
-
function renderGraphicAssociateResponse(interaction, update, currentValue) {
|
|
1701
|
+
function renderGraphicAssociateResponse(interaction, update, currentValue, messages) {
|
|
1403
1702
|
const group = responseGroup();
|
|
1404
1703
|
const width = objectWidth(interaction);
|
|
1405
1704
|
const height = objectHeight(interaction);
|
|
@@ -1407,6 +1706,12 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
|
|
|
1407
1706
|
const selectedPairs = valueToStrings(currentValue);
|
|
1408
1707
|
const maximumAssociations = interaction.responseCardinality === "single" ? 1 : maximumAllowedResponses(interaction);
|
|
1409
1708
|
let selectedHotspot;
|
|
1709
|
+
let draggedHotspot;
|
|
1710
|
+
let dragPointerId;
|
|
1711
|
+
let dragStart;
|
|
1712
|
+
let dragStarted = false;
|
|
1713
|
+
let suppressNextClick = false;
|
|
1714
|
+
let previewLine;
|
|
1410
1715
|
const surface = document.createElement("div");
|
|
1411
1716
|
surface.className = "qti3-graphic-associate-surface";
|
|
1412
1717
|
surface.role = "group";
|
|
@@ -1496,6 +1801,52 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
|
|
|
1496
1801
|
renderState();
|
|
1497
1802
|
commit();
|
|
1498
1803
|
};
|
|
1804
|
+
const authoredPointFromPointer = (event) => {
|
|
1805
|
+
const rect = surface.getBoundingClientRect();
|
|
1806
|
+
return {
|
|
1807
|
+
x: Math.max(0, Math.min(width, ((event.clientX - rect.left) / rect.width) * width)),
|
|
1808
|
+
y: Math.max(0, Math.min(height, ((event.clientY - rect.top) / rect.height) * height)),
|
|
1809
|
+
};
|
|
1810
|
+
};
|
|
1811
|
+
const removePreviewLine = () => {
|
|
1812
|
+
previewLine?.remove();
|
|
1813
|
+
previewLine = undefined;
|
|
1814
|
+
};
|
|
1815
|
+
const suppressFollowingClick = () => {
|
|
1816
|
+
suppressNextClick = true;
|
|
1817
|
+
setTimeout(() => {
|
|
1818
|
+
suppressNextClick = false;
|
|
1819
|
+
}, 0);
|
|
1820
|
+
};
|
|
1821
|
+
const updatePreviewLine = (source, event) => {
|
|
1822
|
+
const start = hotspotCenter(source, width, height);
|
|
1823
|
+
const end = authoredPointFromPointer(event);
|
|
1824
|
+
if (!previewLine) {
|
|
1825
|
+
previewLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
1826
|
+
previewLine.dataset.preview = "true";
|
|
1827
|
+
connections.append(previewLine);
|
|
1828
|
+
}
|
|
1829
|
+
previewLine.setAttribute("x1", String(start.x));
|
|
1830
|
+
previewLine.setAttribute("y1", String(start.y));
|
|
1831
|
+
previewLine.setAttribute("x2", String(end.x));
|
|
1832
|
+
previewLine.setAttribute("y2", String(end.y));
|
|
1833
|
+
};
|
|
1834
|
+
const hotspotFromPointer = (event) => {
|
|
1835
|
+
const element = document.elementFromPoint(event.clientX, event.clientY);
|
|
1836
|
+
const button = element?.closest(".qti3-graphic-associate-hotspot");
|
|
1837
|
+
const identifier = button?.dataset.choiceIdentifier;
|
|
1838
|
+
return choices.find((choice) => choice.identifier === identifier);
|
|
1839
|
+
};
|
|
1840
|
+
const finishDrag = (event, source) => {
|
|
1841
|
+
const target = hotspotFromPointer(event);
|
|
1842
|
+
removePreviewLine();
|
|
1843
|
+
if (target) {
|
|
1844
|
+
addPair(source, target);
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1847
|
+
selectedHotspot = undefined;
|
|
1848
|
+
renderState();
|
|
1849
|
+
};
|
|
1499
1850
|
const chooseHotspot = (choice) => {
|
|
1500
1851
|
if (!selectedHotspot) {
|
|
1501
1852
|
selectedHotspot = choice;
|
|
@@ -1550,10 +1901,7 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
|
|
|
1550
1901
|
item.className = "qti3-pair-chip";
|
|
1551
1902
|
const text = document.createElement("span");
|
|
1552
1903
|
text.textContent = pairLabel;
|
|
1553
|
-
const remove =
|
|
1554
|
-
remove.type = "button";
|
|
1555
|
-
remove.textContent = "Remove";
|
|
1556
|
-
remove.setAttribute("aria-label", `Remove ${pairLabel}`);
|
|
1904
|
+
const remove = removeButton(pairLabel, messages);
|
|
1557
1905
|
remove.addEventListener("click", () => removePair(pair));
|
|
1558
1906
|
item.append(text, remove);
|
|
1559
1907
|
return item;
|
|
@@ -1569,8 +1917,66 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
|
|
|
1569
1917
|
button.setAttribute("aria-pressed", "false");
|
|
1570
1918
|
button.setAttribute("aria-label", hotspotAccessibleLabel(choice, index));
|
|
1571
1919
|
button.style.position = "absolute";
|
|
1920
|
+
button.style.touchAction = "none";
|
|
1572
1921
|
placeHotspotButton(button, choice, width, height);
|
|
1573
|
-
button.addEventListener("click", () =>
|
|
1922
|
+
button.addEventListener("click", (event) => {
|
|
1923
|
+
if (suppressNextClick) {
|
|
1924
|
+
suppressNextClick = false;
|
|
1925
|
+
event.preventDefault();
|
|
1926
|
+
return;
|
|
1927
|
+
}
|
|
1928
|
+
chooseHotspot(choice);
|
|
1929
|
+
});
|
|
1930
|
+
button.addEventListener("pointerdown", (event) => {
|
|
1931
|
+
if (event.button !== 0)
|
|
1932
|
+
return;
|
|
1933
|
+
draggedHotspot = choice;
|
|
1934
|
+
dragPointerId = event.pointerId;
|
|
1935
|
+
dragStart = { x: event.clientX, y: event.clientY };
|
|
1936
|
+
dragStarted = false;
|
|
1937
|
+
button.setPointerCapture(event.pointerId);
|
|
1938
|
+
});
|
|
1939
|
+
button.addEventListener("pointermove", (event) => {
|
|
1940
|
+
if (dragPointerId !== event.pointerId || !draggedHotspot || !dragStart)
|
|
1941
|
+
return;
|
|
1942
|
+
const moved = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
|
1943
|
+
if (!dragStarted && moved < 4)
|
|
1944
|
+
return;
|
|
1945
|
+
if (!dragStarted) {
|
|
1946
|
+
dragStarted = true;
|
|
1947
|
+
suppressFollowingClick();
|
|
1948
|
+
selectedHotspot = draggedHotspot;
|
|
1949
|
+
renderState();
|
|
1950
|
+
}
|
|
1951
|
+
updatePreviewLine(draggedHotspot, event);
|
|
1952
|
+
event.preventDefault();
|
|
1953
|
+
});
|
|
1954
|
+
button.addEventListener("pointerup", (event) => {
|
|
1955
|
+
if (dragPointerId !== event.pointerId || !draggedHotspot)
|
|
1956
|
+
return;
|
|
1957
|
+
const source = draggedHotspot;
|
|
1958
|
+
draggedHotspot = undefined;
|
|
1959
|
+
dragPointerId = undefined;
|
|
1960
|
+
dragStart = undefined;
|
|
1961
|
+
button.releasePointerCapture(event.pointerId);
|
|
1962
|
+
if (!dragStarted)
|
|
1963
|
+
return;
|
|
1964
|
+
dragStarted = false;
|
|
1965
|
+
suppressFollowingClick();
|
|
1966
|
+
finishDrag(event, source);
|
|
1967
|
+
event.preventDefault();
|
|
1968
|
+
});
|
|
1969
|
+
button.addEventListener("pointercancel", (event) => {
|
|
1970
|
+
if (dragPointerId !== event.pointerId)
|
|
1971
|
+
return;
|
|
1972
|
+
draggedHotspot = undefined;
|
|
1973
|
+
dragPointerId = undefined;
|
|
1974
|
+
dragStart = undefined;
|
|
1975
|
+
dragStarted = false;
|
|
1976
|
+
removePreviewLine();
|
|
1977
|
+
selectedHotspot = undefined;
|
|
1978
|
+
renderState();
|
|
1979
|
+
});
|
|
1574
1980
|
button.addEventListener("keydown", (event) => {
|
|
1575
1981
|
if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
1576
1982
|
event.preventDefault();
|
|
@@ -1592,6 +1998,11 @@ function renderGraphicAssociateResponse(interaction, update, currentValue) {
|
|
|
1592
1998
|
return group;
|
|
1593
1999
|
}
|
|
1594
2000
|
function renderGapMatchResponse(interaction, update, currentValue) {
|
|
2001
|
+
if (interaction.type === "graphicGapMatch" &&
|
|
2002
|
+
interaction.object &&
|
|
2003
|
+
interaction.choices.some((choice) => choice.role === "hotspot")) {
|
|
2004
|
+
return renderGraphicGapMatchResponse(interaction, update, currentValue);
|
|
2005
|
+
}
|
|
1595
2006
|
const group = responseGroup();
|
|
1596
2007
|
appendGraphicContext(group, interaction);
|
|
1597
2008
|
const sources = sourceChoices(interaction);
|
|
@@ -1701,6 +2112,154 @@ function renderGapMatchResponse(interaction, update, currentValue) {
|
|
|
1701
2112
|
group.append(sourceRegion, gapRegion);
|
|
1702
2113
|
return group;
|
|
1703
2114
|
}
|
|
2115
|
+
function renderGraphicGapMatchResponse(interaction, update, currentValue) {
|
|
2116
|
+
const group = responseGroup();
|
|
2117
|
+
const width = objectWidth(interaction);
|
|
2118
|
+
const height = objectHeight(interaction);
|
|
2119
|
+
const sources = sourceChoices(interaction);
|
|
2120
|
+
const gaps = targetChoices(interaction).filter((choice) => choice.role === "hotspot");
|
|
2121
|
+
const assignments = new Map();
|
|
2122
|
+
let selectedSource;
|
|
2123
|
+
let draggedSource;
|
|
2124
|
+
for (const pair of valueToStrings(currentValue)) {
|
|
2125
|
+
const [sourceIdentifier, gapIdentifier] = pair.split(/\s+/);
|
|
2126
|
+
const source = sources.find((choice) => choice.identifier === sourceIdentifier);
|
|
2127
|
+
if (source && gapIdentifier)
|
|
2128
|
+
assignments.set(gapIdentifier, source);
|
|
2129
|
+
}
|
|
2130
|
+
const surface = document.createElement("div");
|
|
2131
|
+
surface.className = "qti3-graphic-context qti3-graphic-gap-match-surface";
|
|
2132
|
+
surface.role = "group";
|
|
2133
|
+
surface.setAttribute("aria-label", `${readableType(interaction.type)} target image`);
|
|
2134
|
+
surface.style.position = "relative";
|
|
2135
|
+
surface.style.inlineSize = `${width}px`;
|
|
2136
|
+
surface.style.aspectRatio = `${width} / ${height}`;
|
|
2137
|
+
surface.style.maxInlineSize = "100%";
|
|
2138
|
+
surface.style.border = "1px solid CanvasText";
|
|
2139
|
+
surface.style.background = "Canvas";
|
|
2140
|
+
surface.style.overflow = "visible";
|
|
2141
|
+
surface.style.setProperty("--qti3-graphic-gap-label-block-size", `${graphicGapLabelBlockSize(sources)}rem`);
|
|
2142
|
+
if (interaction.object?.data && objectIsImage(interaction.object)) {
|
|
2143
|
+
const image = document.createElement("img");
|
|
2144
|
+
image.src = interaction.object.data;
|
|
2145
|
+
image.alt = interaction.object.text || `${readableType(interaction.type)} image`;
|
|
2146
|
+
image.style.position = "absolute";
|
|
2147
|
+
image.style.inset = "0";
|
|
2148
|
+
image.style.inlineSize = "100%";
|
|
2149
|
+
image.style.blockSize = "100%";
|
|
2150
|
+
image.style.objectFit = "contain";
|
|
2151
|
+
image.style.pointerEvents = "none";
|
|
2152
|
+
surface.append(image);
|
|
2153
|
+
}
|
|
2154
|
+
const sourceRegion = tokenRegion(`${readableType(interaction.type)} choices`);
|
|
2155
|
+
sourceRegion.classList.add("qti3-graphic-gap-source-region");
|
|
2156
|
+
const choicesWidth = positivePixelValue(interaction.attributes["data-choices-container-width"]);
|
|
2157
|
+
if (choicesWidth !== undefined)
|
|
2158
|
+
sourceRegion.style.maxInlineSize = `${choicesWidth}px`;
|
|
2159
|
+
const summary = document.createElement("p");
|
|
2160
|
+
summary.className = "qti3-selection-summary";
|
|
2161
|
+
summary.setAttribute("aria-live", "polite");
|
|
2162
|
+
const commit = () => {
|
|
2163
|
+
update([...assignments.entries()].map(([gapIdentifier, source]) => `${source.identifier} ${gapIdentifier}`));
|
|
2164
|
+
};
|
|
2165
|
+
const syncSources = () => {
|
|
2166
|
+
for (const button of sourceRegion.querySelectorAll("button")) {
|
|
2167
|
+
button.setAttribute("aria-pressed", button.dataset.choiceIdentifier === selectedSource?.identifier ? "true" : "false");
|
|
2168
|
+
}
|
|
2169
|
+
};
|
|
2170
|
+
const clearSourceIfSingleUse = (source, keepGapIdentifier) => {
|
|
2171
|
+
if (parseUnlimitedMaximum(source.attributes["match-max"]) !== 1)
|
|
2172
|
+
return;
|
|
2173
|
+
for (const [gapIdentifier, assigned] of assignments.entries()) {
|
|
2174
|
+
if (gapIdentifier !== keepGapIdentifier && assigned.identifier === source.identifier) {
|
|
2175
|
+
assignments.delete(gapIdentifier);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
const assign = (gap, sourceIdentifier) => {
|
|
2180
|
+
const source = sources.find((choice) => choice.identifier === sourceIdentifier);
|
|
2181
|
+
if (!source)
|
|
2182
|
+
return;
|
|
2183
|
+
clearSourceIfSingleUse(source, gap.identifier);
|
|
2184
|
+
assignments.set(gap.identifier, source);
|
|
2185
|
+
selectedSource = undefined;
|
|
2186
|
+
syncSources();
|
|
2187
|
+
renderTargets();
|
|
2188
|
+
commit();
|
|
2189
|
+
};
|
|
2190
|
+
const targetLabel = (gap, index) => gap.attributes["aria-label"] || gap.attributes["hotspot-label"] || `Target ${index + 1}`;
|
|
2191
|
+
const renderTargetButton = (gap, index) => {
|
|
2192
|
+
const assigned = assignments.get(gap.identifier);
|
|
2193
|
+
const label = targetLabel(gap, index);
|
|
2194
|
+
const button = document.createElement("button");
|
|
2195
|
+
button.type = "button";
|
|
2196
|
+
button.className = "qti3-hotspot-button qti3-graphic-gap-hotspot";
|
|
2197
|
+
button.dataset.gapIdentifier = gap.identifier;
|
|
2198
|
+
button.dataset.selected = assigned ? "true" : "false";
|
|
2199
|
+
button.setAttribute("aria-label", assigned ? `${label}, assigned ${assigned.text}` : `${label}, empty`);
|
|
2200
|
+
button.addEventListener("dragover", (event) => {
|
|
2201
|
+
event.preventDefault();
|
|
2202
|
+
button.classList.add("qti3-drop-target");
|
|
2203
|
+
});
|
|
2204
|
+
button.addEventListener("dragleave", () => button.classList.remove("qti3-drop-target"));
|
|
2205
|
+
button.addEventListener("drop", (event) => {
|
|
2206
|
+
event.preventDefault();
|
|
2207
|
+
button.classList.remove("qti3-drop-target");
|
|
2208
|
+
assign(gap, event.dataTransfer?.getData("text/plain") || draggedSource);
|
|
2209
|
+
});
|
|
2210
|
+
button.addEventListener("click", () => assign(gap, selectedSource?.identifier));
|
|
2211
|
+
button.addEventListener("keydown", (event) => {
|
|
2212
|
+
if (event.key !== "Delete" && event.key !== "Backspace")
|
|
2213
|
+
return;
|
|
2214
|
+
if (!assignments.has(gap.identifier))
|
|
2215
|
+
return;
|
|
2216
|
+
event.preventDefault();
|
|
2217
|
+
assignments.delete(gap.identifier);
|
|
2218
|
+
renderTargets();
|
|
2219
|
+
commit();
|
|
2220
|
+
});
|
|
2221
|
+
button.style.position = "absolute";
|
|
2222
|
+
placeHotspotButton(button, gap, width, height);
|
|
2223
|
+
if (assigned) {
|
|
2224
|
+
const assignedLabel = document.createElement("span");
|
|
2225
|
+
assignedLabel.className = "qti3-graphic-gap-label";
|
|
2226
|
+
assignedLabel.textContent = assigned.text;
|
|
2227
|
+
button.append(assignedLabel);
|
|
2228
|
+
}
|
|
2229
|
+
return button;
|
|
2230
|
+
};
|
|
2231
|
+
const renderTargets = () => {
|
|
2232
|
+
surface.querySelectorAll(".qti3-graphic-gap-hotspot").forEach((target) => target.remove());
|
|
2233
|
+
for (const [index, gap] of gaps.entries()) {
|
|
2234
|
+
surface.append(renderTargetButton(gap, index));
|
|
2235
|
+
}
|
|
2236
|
+
summary.textContent =
|
|
2237
|
+
assignments.size > 0
|
|
2238
|
+
? `${assignments.size} ${assignments.size === 1 ? "label" : "labels"} placed.`
|
|
2239
|
+
: "No labels placed.";
|
|
2240
|
+
};
|
|
2241
|
+
for (const source of sources) {
|
|
2242
|
+
const button = tokenButton(source);
|
|
2243
|
+
button.draggable = true;
|
|
2244
|
+
button.addEventListener("dragstart", (event) => {
|
|
2245
|
+
draggedSource = source.identifier;
|
|
2246
|
+
event.dataTransfer?.setData("text/plain", source.identifier);
|
|
2247
|
+
event.dataTransfer?.setDragImage(button, 8, 8);
|
|
2248
|
+
});
|
|
2249
|
+
button.addEventListener("dragend", () => {
|
|
2250
|
+
draggedSource = undefined;
|
|
2251
|
+
syncSources();
|
|
2252
|
+
});
|
|
2253
|
+
button.addEventListener("click", () => {
|
|
2254
|
+
selectedSource = source;
|
|
2255
|
+
syncSources();
|
|
2256
|
+
});
|
|
2257
|
+
sourceRegion.append(button);
|
|
2258
|
+
}
|
|
2259
|
+
renderTargets();
|
|
2260
|
+
group.append(surface, sourceRegion, summary);
|
|
2261
|
+
return group;
|
|
2262
|
+
}
|
|
1704
2263
|
function renderSelect(interaction, update, currentValue) {
|
|
1705
2264
|
const select = document.createElement("select");
|
|
1706
2265
|
select.className = "qti3-inline-select";
|
|
@@ -2019,10 +2578,9 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
|
|
|
2019
2578
|
const height = objectAssetHeight(stageObject, 300);
|
|
2020
2579
|
const movableWidth = objectAssetWidth(movableObject, Math.max(32, Math.round(width * 0.12)));
|
|
2021
2580
|
const movableHeight = objectAssetHeight(movableObject, Math.max(32, Math.round(height * 0.12)));
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
};
|
|
2581
|
+
const parsedPoint = parsePointValue(currentValue);
|
|
2582
|
+
let point = parsedPoint ?? { x: 0, y: 0 };
|
|
2583
|
+
let isPlaced = Boolean(parsedPoint);
|
|
2026
2584
|
const stage = document.createElement("div");
|
|
2027
2585
|
stage.className = "qti3-position-object-stage";
|
|
2028
2586
|
stage.tabIndex = 0;
|
|
@@ -2035,8 +2593,9 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
|
|
|
2035
2593
|
stage.style.border = "1px solid CanvasText";
|
|
2036
2594
|
stage.style.background = "Canvas";
|
|
2037
2595
|
stage.style.color = "CanvasText";
|
|
2038
|
-
stage.style.overflow = "
|
|
2596
|
+
stage.style.overflow = "visible";
|
|
2039
2597
|
stage.style.touchAction = "none";
|
|
2598
|
+
stage.style.marginBlockEnd = `${Math.ceil(movableHeight + 12)}px`;
|
|
2040
2599
|
if (stageObject?.data && objectIsImage(stageObject)) {
|
|
2041
2600
|
const image = document.createElement("img");
|
|
2042
2601
|
image.src = stageObject.data;
|
|
@@ -2085,10 +2644,22 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
|
|
|
2085
2644
|
point.y = Math.max(0, Math.min(height, point.y));
|
|
2086
2645
|
};
|
|
2087
2646
|
const commit = () => {
|
|
2647
|
+
if (!isPlaced)
|
|
2648
|
+
return;
|
|
2088
2649
|
update(pointToString(point));
|
|
2089
2650
|
};
|
|
2090
2651
|
const syncMarker = () => {
|
|
2652
|
+
if (!isPlaced) {
|
|
2653
|
+
marker.dataset.placed = "false";
|
|
2654
|
+
marker.style.insetInlineStart = `${Math.round(movableWidth / 2)}px`;
|
|
2655
|
+
marker.style.insetBlockStart = `calc(100% + ${Math.round(movableHeight / 2 + 8)}px)`;
|
|
2656
|
+
coordinate.value = "";
|
|
2657
|
+
coordinate.textContent = "Object not placed";
|
|
2658
|
+
stage.setAttribute("aria-label", `${readableType(interaction.type)} placement stage, object not placed`);
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2091
2661
|
clamp();
|
|
2662
|
+
marker.dataset.placed = "true";
|
|
2092
2663
|
marker.style.insetInlineStart = `${percent(point.x, width)}%`;
|
|
2093
2664
|
marker.style.insetBlockStart = `${percent(point.y, height)}%`;
|
|
2094
2665
|
coordinate.value = pointToString(point);
|
|
@@ -2101,9 +2672,17 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
|
|
|
2101
2672
|
x: Math.round(((event.clientX - rect.left) / rect.width) * width),
|
|
2102
2673
|
y: Math.round(((event.clientY - rect.top) / rect.height) * height),
|
|
2103
2674
|
};
|
|
2675
|
+
isPlaced = true;
|
|
2104
2676
|
clamp();
|
|
2105
2677
|
};
|
|
2678
|
+
const ensureKeyboardPoint = () => {
|
|
2679
|
+
if (isPlaced)
|
|
2680
|
+
return;
|
|
2681
|
+
point = { x: 0, y: 0 };
|
|
2682
|
+
isPlaced = true;
|
|
2683
|
+
};
|
|
2106
2684
|
const moveBy = (dx, dy, emit = true) => {
|
|
2685
|
+
ensureKeyboardPoint();
|
|
2107
2686
|
point.x += dx;
|
|
2108
2687
|
point.y += dy;
|
|
2109
2688
|
syncMarker();
|
|
@@ -2120,24 +2699,32 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
|
|
|
2120
2699
|
moveBy(0, -step, false);
|
|
2121
2700
|
else if (event.key === "ArrowDown")
|
|
2122
2701
|
moveBy(0, step, false);
|
|
2123
|
-
else if (event.key === "Enter" || event.key === " ")
|
|
2702
|
+
else if (event.key === "Enter" || event.key === " ") {
|
|
2703
|
+
ensureKeyboardPoint();
|
|
2704
|
+
syncMarker();
|
|
2124
2705
|
commit();
|
|
2706
|
+
}
|
|
2125
2707
|
else
|
|
2126
2708
|
return;
|
|
2127
2709
|
event.preventDefault();
|
|
2128
2710
|
};
|
|
2129
2711
|
let dragging = false;
|
|
2712
|
+
let dragMoved = false;
|
|
2130
2713
|
marker.addEventListener("pointerdown", (event) => {
|
|
2131
2714
|
dragging = true;
|
|
2715
|
+
dragMoved = false;
|
|
2132
2716
|
marker.setPointerCapture(event.pointerId);
|
|
2133
2717
|
marker.style.cursor = "grabbing";
|
|
2134
|
-
|
|
2135
|
-
|
|
2718
|
+
if (isPlaced) {
|
|
2719
|
+
pointFromPointer(event);
|
|
2720
|
+
syncMarker();
|
|
2721
|
+
}
|
|
2136
2722
|
event.preventDefault();
|
|
2137
2723
|
});
|
|
2138
2724
|
marker.addEventListener("pointermove", (event) => {
|
|
2139
2725
|
if (!dragging)
|
|
2140
2726
|
return;
|
|
2727
|
+
dragMoved = true;
|
|
2141
2728
|
pointFromPointer(event);
|
|
2142
2729
|
syncMarker();
|
|
2143
2730
|
});
|
|
@@ -2147,9 +2734,11 @@ function renderPositionObjectResponse(interaction, update, currentValue) {
|
|
|
2147
2734
|
dragging = false;
|
|
2148
2735
|
marker.releasePointerCapture(event.pointerId);
|
|
2149
2736
|
marker.style.cursor = "grab";
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2737
|
+
if (dragMoved || isPlaced) {
|
|
2738
|
+
pointFromPointer(event);
|
|
2739
|
+
syncMarker();
|
|
2740
|
+
commit();
|
|
2741
|
+
}
|
|
2153
2742
|
});
|
|
2154
2743
|
marker.addEventListener("pointercancel", () => {
|
|
2155
2744
|
dragging = false;
|
|
@@ -2317,45 +2906,6 @@ function renderDrawingResponse(interaction, update, currentValue) {
|
|
|
2317
2906
|
group.append(surface, summary, tools);
|
|
2318
2907
|
return group;
|
|
2319
2908
|
}
|
|
2320
|
-
function renderPortableCustomResponse(interaction, update, currentValue) {
|
|
2321
|
-
const group = document.createElement("div");
|
|
2322
|
-
group.role = "group";
|
|
2323
|
-
group.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction");
|
|
2324
|
-
const host = document.createElement("div");
|
|
2325
|
-
host.className = "qti3-portable-custom-host";
|
|
2326
|
-
host.tabIndex = 0;
|
|
2327
|
-
host.dataset.responseIdentifier = interaction.responseIdentifier ?? "";
|
|
2328
|
-
host.dataset.typeIdentifier = interaction.attributes["custom-interaction-type-identifier"] ?? "";
|
|
2329
|
-
host.dataset.module = interaction.attributes.module ?? "";
|
|
2330
|
-
host.dataset.qtiName = interaction.qtiName;
|
|
2331
|
-
host.setAttribute("role", "application");
|
|
2332
|
-
host.setAttribute("aria-label", interaction.prompt ?? "Portable custom interaction host");
|
|
2333
|
-
host.textContent = "Portable custom interaction host";
|
|
2334
|
-
host.style.border = "1px solid CanvasText";
|
|
2335
|
-
host.style.padding = "0.5rem";
|
|
2336
|
-
host.style.marginBlockEnd = "0.5rem";
|
|
2337
|
-
const fallback = document.createElement("input");
|
|
2338
|
-
fallback.value = scalarString(currentValue);
|
|
2339
|
-
fallback.setAttribute("aria-label", `${interaction.prompt ?? "Portable custom"} response`);
|
|
2340
|
-
fallback.addEventListener("input", () => update(fallback.value));
|
|
2341
|
-
fallback.addEventListener("change", () => update(fallback.value));
|
|
2342
|
-
host.addEventListener("qti3-portable-custom-response", (event) => {
|
|
2343
|
-
const value = portableCustomEventValue(event);
|
|
2344
|
-
if (value === undefined)
|
|
2345
|
-
return;
|
|
2346
|
-
fallback.value = String(value ?? "");
|
|
2347
|
-
update(value);
|
|
2348
|
-
});
|
|
2349
|
-
host.addEventListener("qti3-pci-response", (event) => {
|
|
2350
|
-
const value = portableCustomEventValue(event);
|
|
2351
|
-
if (value === undefined)
|
|
2352
|
-
return;
|
|
2353
|
-
fallback.value = String(value ?? "");
|
|
2354
|
-
update(value);
|
|
2355
|
-
});
|
|
2356
|
-
group.append(host, fallback);
|
|
2357
|
-
return group;
|
|
2358
|
-
}
|
|
2359
2909
|
function renderHotspotResponse(interaction, update, currentValue) {
|
|
2360
2910
|
const group = responseGroup();
|
|
2361
2911
|
const surface = document.createElement("div");
|
|
@@ -2490,6 +3040,7 @@ function configureMediaElement(media, interaction, object, label, mediaResponse)
|
|
|
2490
3040
|
sourceElement.src = source.src;
|
|
2491
3041
|
if (source.type)
|
|
2492
3042
|
sourceElement.type = source.type;
|
|
3043
|
+
copySafeMediaChildAttributes(sourceElement, source.attributes, sourceAttributeNames);
|
|
2493
3044
|
media.append(sourceElement);
|
|
2494
3045
|
}
|
|
2495
3046
|
for (const track of object.tracks) {
|
|
@@ -2505,6 +3056,7 @@ function configureMediaElement(media, interaction, object, label, mediaResponse)
|
|
|
2505
3056
|
trackElement.label = track.label;
|
|
2506
3057
|
if (track.default)
|
|
2507
3058
|
trackElement.default = true;
|
|
3059
|
+
copySafeMediaChildAttributes(trackElement, track.attributes, trackAttributeNames);
|
|
2508
3060
|
media.append(trackElement);
|
|
2509
3061
|
}
|
|
2510
3062
|
bindMediaPlayCount(media, interaction, mediaResponse);
|
|
@@ -2516,6 +3068,23 @@ function copyMediaDataAttributes(element, attributes) {
|
|
|
2516
3068
|
element.setAttribute(name, value);
|
|
2517
3069
|
}
|
|
2518
3070
|
}
|
|
3071
|
+
const sourceAttributeNames = new Set(["src", "srcset", "type"]);
|
|
3072
|
+
const trackAttributeNames = new Set(["default", "kind", "label", "src", "srclang"]);
|
|
3073
|
+
function copySafeMediaChildAttributes(element, attributes, controlledNames) {
|
|
3074
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
3075
|
+
const normalizedName = name.toLowerCase();
|
|
3076
|
+
if (controlledNames.has(normalizedName))
|
|
3077
|
+
continue;
|
|
3078
|
+
if (normalizedName === "class" ||
|
|
3079
|
+
normalizedName === "id" ||
|
|
3080
|
+
normalizedName === "title" ||
|
|
3081
|
+
normalizedName === "media" ||
|
|
3082
|
+
normalizedName === "sizes" ||
|
|
3083
|
+
normalizedName.startsWith("data-")) {
|
|
3084
|
+
element.setAttribute(name, value);
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
2519
3088
|
function mediaElementType(object) {
|
|
2520
3089
|
const types = [object.type, ...object.sources.map((source) => source.type)].filter((value) => Boolean(value));
|
|
2521
3090
|
if (types.some((value) => value.startsWith("audio/")))
|
|
@@ -2625,6 +3194,10 @@ function choiceText(choices, identifier) {
|
|
|
2625
3194
|
}
|
|
2626
3195
|
function sourceChoices(interaction) {
|
|
2627
3196
|
const choices = choicesOrFallback(interaction);
|
|
3197
|
+
if (interaction.type === "gapMatch" || interaction.type === "graphicGapMatch") {
|
|
3198
|
+
const gapChoices = choices.filter((choice) => choice.role === "gapChoice");
|
|
3199
|
+
return gapChoices.length > 0 ? gapChoices : choices;
|
|
3200
|
+
}
|
|
2628
3201
|
const sourceRoles = new Set(["associableChoice", "matchSource", "gapChoice", "hotspot"]);
|
|
2629
3202
|
const sources = choices.filter((choice) => sourceRoles.has(choice.role));
|
|
2630
3203
|
return sources.length > 0 ? sources : choices;
|
|
@@ -2797,6 +3370,15 @@ function dimension(value, fallback) {
|
|
|
2797
3370
|
const parsed = Number(value);
|
|
2798
3371
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
2799
3372
|
}
|
|
3373
|
+
function positivePixelValue(value) {
|
|
3374
|
+
const parsed = Number(value);
|
|
3375
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
3376
|
+
}
|
|
3377
|
+
function graphicGapLabelBlockSize(sources) {
|
|
3378
|
+
const maxLength = Math.max(0, ...sources.map((source) => (source.text || source.identifier).trim().length));
|
|
3379
|
+
const estimatedLines = Math.max(1, Math.ceil(maxLength / 22));
|
|
3380
|
+
return Number((estimatedLines * 0.95 + 0.9).toFixed(2));
|
|
3381
|
+
}
|
|
2800
3382
|
function placeHotspotButton(button, choice, width, height) {
|
|
2801
3383
|
const coords = (choice.attributes.coords ?? "")
|
|
2802
3384
|
.split(",")
|
|
@@ -3076,6 +3658,20 @@ function polylineElement(points) {
|
|
|
3076
3658
|
line.setAttribute("stroke-linejoin", "round");
|
|
3077
3659
|
return line;
|
|
3078
3660
|
}
|
|
3661
|
+
function portableCustomDefinitionFromAttributes(interaction) {
|
|
3662
|
+
return {
|
|
3663
|
+
responseIdentifier: interaction.responseIdentifier,
|
|
3664
|
+
customInteractionTypeIdentifier: interaction.attributes["custom-interaction-type-identifier"],
|
|
3665
|
+
module: interaction.attributes.module,
|
|
3666
|
+
interactionMarkup: [],
|
|
3667
|
+
templateVariables: [],
|
|
3668
|
+
contextVariables: [],
|
|
3669
|
+
stylesheets: [],
|
|
3670
|
+
dataAttributes: Object.fromEntries(Object.entries(interaction.attributes).filter(([name]) => name.startsWith("data-"))),
|
|
3671
|
+
attributes: interaction.attributes,
|
|
3672
|
+
source: interaction.source,
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3079
3675
|
function portableCustomEventValue(event) {
|
|
3080
3676
|
if (!("detail" in event))
|
|
3081
3677
|
return undefined;
|
|
@@ -3087,13 +3683,51 @@ function portableCustomEventValue(event) {
|
|
|
3087
3683
|
return detail.value ?? null;
|
|
3088
3684
|
if ("response" in detail)
|
|
3089
3685
|
return detail.response ?? null;
|
|
3686
|
+
if ("state" in detail || "valid" in detail)
|
|
3687
|
+
return undefined;
|
|
3090
3688
|
}
|
|
3091
3689
|
return detail;
|
|
3092
3690
|
}
|
|
3691
|
+
function portableCustomEventState(event) {
|
|
3692
|
+
if (!("detail" in event))
|
|
3693
|
+
return undefined;
|
|
3694
|
+
const detail = event.detail;
|
|
3695
|
+
if (typeof detail !== "object" || detail === null || !("state" in detail))
|
|
3696
|
+
return undefined;
|
|
3697
|
+
return isPortableCustomStateValue(detail.state) ? detail.state : undefined;
|
|
3698
|
+
}
|
|
3699
|
+
function portableCustomEventValidity(event) {
|
|
3700
|
+
if (!("detail" in event))
|
|
3701
|
+
return undefined;
|
|
3702
|
+
const detail = event.detail;
|
|
3703
|
+
if (typeof detail !== "object" || detail === null || typeof detail.valid !== "boolean") {
|
|
3704
|
+
return undefined;
|
|
3705
|
+
}
|
|
3706
|
+
return {
|
|
3707
|
+
valid: detail.valid,
|
|
3708
|
+
message: typeof detail.message === "string" ? detail.message : undefined,
|
|
3709
|
+
};
|
|
3710
|
+
}
|
|
3711
|
+
function isPortableCustomStateValue(value) {
|
|
3712
|
+
if (value === null)
|
|
3713
|
+
return true;
|
|
3714
|
+
if (typeof value === "string" || typeof value === "boolean")
|
|
3715
|
+
return true;
|
|
3716
|
+
if (typeof value === "number")
|
|
3717
|
+
return Number.isFinite(value);
|
|
3718
|
+
if (Array.isArray(value))
|
|
3719
|
+
return value.every(isPortableCustomStateValue);
|
|
3720
|
+
if (typeof value === "object") {
|
|
3721
|
+
return Object.values(value).every(isPortableCustomStateValue);
|
|
3722
|
+
}
|
|
3723
|
+
return false;
|
|
3724
|
+
}
|
|
3093
3725
|
const htmlContentElements = new Set([
|
|
3094
3726
|
"a",
|
|
3095
3727
|
"abbr",
|
|
3096
3728
|
"b",
|
|
3729
|
+
"bdi",
|
|
3730
|
+
"bdo",
|
|
3097
3731
|
"blockquote",
|
|
3098
3732
|
"br",
|
|
3099
3733
|
"caption",
|
|
@@ -3107,6 +3741,12 @@ const htmlContentElements = new Set([
|
|
|
3107
3741
|
"em",
|
|
3108
3742
|
"figcaption",
|
|
3109
3743
|
"figure",
|
|
3744
|
+
"h1",
|
|
3745
|
+
"h2",
|
|
3746
|
+
"h3",
|
|
3747
|
+
"h4",
|
|
3748
|
+
"h5",
|
|
3749
|
+
"h6",
|
|
3110
3750
|
"hr",
|
|
3111
3751
|
"i",
|
|
3112
3752
|
"img",
|
|
@@ -3116,6 +3756,12 @@ const htmlContentElements = new Set([
|
|
|
3116
3756
|
"p",
|
|
3117
3757
|
"pre",
|
|
3118
3758
|
"q",
|
|
3759
|
+
"rb",
|
|
3760
|
+
"rbc",
|
|
3761
|
+
"rp",
|
|
3762
|
+
"rt",
|
|
3763
|
+
"rtc",
|
|
3764
|
+
"ruby",
|
|
3119
3765
|
"samp",
|
|
3120
3766
|
"small",
|
|
3121
3767
|
"span",
|
|
@@ -3132,6 +3778,7 @@ const htmlContentElements = new Set([
|
|
|
3132
3778
|
"ul",
|
|
3133
3779
|
"var",
|
|
3134
3780
|
]);
|
|
3781
|
+
const unsafeContentElements = new Set(["script", "style"]);
|
|
3135
3782
|
const mathMlElements = new Set([
|
|
3136
3783
|
"math",
|
|
3137
3784
|
"maction",
|
|
@@ -3200,32 +3847,80 @@ function copySafeAttributes(element, attributes) {
|
|
|
3200
3847
|
if (!isSafeContentAttribute(name, value))
|
|
3201
3848
|
continue;
|
|
3202
3849
|
element.setAttribute(name, value);
|
|
3850
|
+
if (name === "xml:lang" && !Object.hasOwn(attributes, "lang")) {
|
|
3851
|
+
element.setAttribute("lang", value);
|
|
3852
|
+
}
|
|
3203
3853
|
}
|
|
3854
|
+
applySharedAccessibilityVocabulary(element, attributes);
|
|
3855
|
+
}
|
|
3856
|
+
function applySharedAccessibilityVocabulary(element, attributes) {
|
|
3857
|
+
for (const [name, value] of Object.entries(attributes)) {
|
|
3858
|
+
const ariaName = qtiAriaAttributeName(name);
|
|
3859
|
+
if (!ariaName || hasAttributeName(attributes, ariaName))
|
|
3860
|
+
continue;
|
|
3861
|
+
element.setAttribute(ariaName, value);
|
|
3862
|
+
}
|
|
3863
|
+
const suppressTts = attributeValue(attributes, "data-qti-suppress-tts");
|
|
3864
|
+
if (suppressesScreenReaderSpeech(suppressTts) &&
|
|
3865
|
+
!hasAttributeName(attributes, "aria-hidden") &&
|
|
3866
|
+
!hasAttributeName(attributes, "data-qti-aria-hidden")) {
|
|
3867
|
+
element.setAttribute("aria-hidden", "true");
|
|
3868
|
+
}
|
|
3869
|
+
}
|
|
3870
|
+
function qtiAriaAttributeName(name) {
|
|
3871
|
+
const normalizedName = name.toLowerCase();
|
|
3872
|
+
const prefix = "data-qti-aria-";
|
|
3873
|
+
if (!normalizedName.startsWith(prefix))
|
|
3874
|
+
return undefined;
|
|
3875
|
+
const suffix = normalizedName.slice(prefix.length);
|
|
3876
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(suffix))
|
|
3877
|
+
return undefined;
|
|
3878
|
+
return `aria-${suffix}`;
|
|
3879
|
+
}
|
|
3880
|
+
function attributeValue(attributes, name) {
|
|
3881
|
+
const normalizedName = name.toLowerCase();
|
|
3882
|
+
const entry = Object.entries(attributes).find(([attributeName]) => attributeName.toLowerCase() === normalizedName);
|
|
3883
|
+
return entry?.[1];
|
|
3884
|
+
}
|
|
3885
|
+
function hasAttributeName(attributes, name) {
|
|
3886
|
+
return attributeValue(attributes, name) !== undefined;
|
|
3887
|
+
}
|
|
3888
|
+
function suppressesScreenReaderSpeech(value) {
|
|
3889
|
+
if (!value)
|
|
3890
|
+
return false;
|
|
3891
|
+
const tokens = value
|
|
3892
|
+
.toLowerCase()
|
|
3893
|
+
.split(/[\s,]+/)
|
|
3894
|
+
.filter(Boolean);
|
|
3895
|
+
return tokens.includes("all") || tokens.includes("screen-reader");
|
|
3204
3896
|
}
|
|
3205
3897
|
function isSafeContentAttribute(name, value) {
|
|
3206
|
-
|
|
3898
|
+
const normalizedName = name.toLowerCase();
|
|
3899
|
+
if (normalizedName.startsWith("on"))
|
|
3207
3900
|
return false;
|
|
3208
|
-
if (
|
|
3901
|
+
if (normalizedName === "style")
|
|
3209
3902
|
return false;
|
|
3210
|
-
if (
|
|
3903
|
+
if (normalizedName === "href" || normalizedName === "src" || normalizedName === "data") {
|
|
3211
3904
|
return isSafeUrl(value);
|
|
3212
3905
|
}
|
|
3213
|
-
return (
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3906
|
+
return (normalizedName === "alt" ||
|
|
3907
|
+
normalizedName === "class" ||
|
|
3908
|
+
normalizedName === "colspan" ||
|
|
3909
|
+
normalizedName === "dir" ||
|
|
3910
|
+
normalizedName === "headers" ||
|
|
3911
|
+
normalizedName === "height" ||
|
|
3912
|
+
normalizedName === "id" ||
|
|
3913
|
+
normalizedName === "lang" ||
|
|
3914
|
+
normalizedName === "role" ||
|
|
3915
|
+
normalizedName === "rowspan" ||
|
|
3916
|
+
normalizedName === "scope" ||
|
|
3917
|
+
normalizedName === "title" ||
|
|
3918
|
+
normalizedName === "type" ||
|
|
3919
|
+
normalizedName === "width" ||
|
|
3920
|
+
normalizedName === "xml:lang" ||
|
|
3921
|
+
mathMlAttributeNames.has(normalizedName) ||
|
|
3922
|
+
normalizedName.startsWith("aria-") ||
|
|
3923
|
+
normalizedName.startsWith("data-"));
|
|
3229
3924
|
}
|
|
3230
3925
|
const mathMlAttributeNames = new Set([
|
|
3231
3926
|
"accent",
|
|
@@ -3360,6 +4055,23 @@ function playerStyleElement() {
|
|
|
3360
4055
|
margin-block: 0;
|
|
3361
4056
|
}
|
|
3362
4057
|
|
|
4058
|
+
.qti3-player .qti-hidden {
|
|
4059
|
+
display: none !important;
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
.qti3-player .qti-visually-hidden {
|
|
4063
|
+
position: absolute !important;
|
|
4064
|
+
overflow: hidden !important;
|
|
4065
|
+
clip: rect(1px, 1px, 1px, 1px) !important;
|
|
4066
|
+
clip-path: inset(50%) !important;
|
|
4067
|
+
inline-size: 1px !important;
|
|
4068
|
+
block-size: 1px !important;
|
|
4069
|
+
margin: -1px !important;
|
|
4070
|
+
padding: 0 !important;
|
|
4071
|
+
border: 0 !important;
|
|
4072
|
+
white-space: nowrap !important;
|
|
4073
|
+
}
|
|
4074
|
+
|
|
3363
4075
|
.qti3-embedded-interaction {
|
|
3364
4076
|
display: inline-flex;
|
|
3365
4077
|
gap: 0.35rem;
|
|
@@ -3584,6 +4296,37 @@ function playerStyleElement() {
|
|
|
3584
4296
|
line-height: 1;
|
|
3585
4297
|
}
|
|
3586
4298
|
|
|
4299
|
+
.qti3-remove-button {
|
|
4300
|
+
border: 1px solid currentColor;
|
|
4301
|
+
background: transparent;
|
|
4302
|
+
color: inherit;
|
|
4303
|
+
cursor: pointer;
|
|
4304
|
+
}
|
|
4305
|
+
|
|
4306
|
+
.qti3-remove-button:hover {
|
|
4307
|
+
background: color-mix(in srgb, currentColor 14%, transparent);
|
|
4308
|
+
}
|
|
4309
|
+
|
|
4310
|
+
.qti3-trash-icon {
|
|
4311
|
+
inline-size: 1.125rem;
|
|
4312
|
+
block-size: 1.125rem;
|
|
4313
|
+
}
|
|
4314
|
+
|
|
4315
|
+
.qti3-movement-icon {
|
|
4316
|
+
inline-size: 1rem;
|
|
4317
|
+
block-size: 1rem;
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4320
|
+
.qti3-trash-icon path,
|
|
4321
|
+
.qti3-movement-icon path {
|
|
4322
|
+
fill: none;
|
|
4323
|
+
stroke: currentColor;
|
|
4324
|
+
stroke-width: 2;
|
|
4325
|
+
stroke-linecap: round;
|
|
4326
|
+
stroke-linejoin: round;
|
|
4327
|
+
vector-effect: non-scaling-stroke;
|
|
4328
|
+
}
|
|
4329
|
+
|
|
3587
4330
|
.qti3-token[aria-pressed="true"],
|
|
3588
4331
|
.qti3-pair-chip {
|
|
3589
4332
|
background: Highlight;
|
|
@@ -3732,6 +4475,7 @@ function playerStyleElement() {
|
|
|
3732
4475
|
}
|
|
3733
4476
|
|
|
3734
4477
|
.qti3-graphic-associate-surface,
|
|
4478
|
+
.qti3-graphic-gap-match-surface,
|
|
3735
4479
|
.qti3-graphic-order-surface {
|
|
3736
4480
|
touch-action: manipulation;
|
|
3737
4481
|
}
|
|
@@ -3759,10 +4503,60 @@ function playerStyleElement() {
|
|
|
3759
4503
|
}
|
|
3760
4504
|
|
|
3761
4505
|
.qti3-graphic-associate-hotspot,
|
|
4506
|
+
.qti3-graphic-gap-hotspot,
|
|
3762
4507
|
.qti3-graphic-order-hotspot {
|
|
3763
4508
|
z-index: 2;
|
|
3764
4509
|
}
|
|
3765
4510
|
|
|
4511
|
+
.qti3-graphic-gap-match-surface {
|
|
4512
|
+
margin-block-end: calc(var(--qti3-graphic-gap-label-block-size, 2rem) + 0.75rem);
|
|
4513
|
+
}
|
|
4514
|
+
|
|
4515
|
+
.qti3-graphic-gap-hotspot {
|
|
4516
|
+
display: grid;
|
|
4517
|
+
place-items: center;
|
|
4518
|
+
padding: 0;
|
|
4519
|
+
overflow: visible;
|
|
4520
|
+
border-style: dashed;
|
|
4521
|
+
background: rgb(255 255 255 / 0.08);
|
|
4522
|
+
color: CanvasText;
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
.qti3-graphic-gap-hotspot[data-selected="true"] {
|
|
4526
|
+
border-style: solid;
|
|
4527
|
+
background: color-mix(in srgb, Highlight 18%, Canvas);
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
.qti3-graphic-gap-label {
|
|
4531
|
+
position: absolute;
|
|
4532
|
+
inset-block-start: calc(100% + 0.2rem);
|
|
4533
|
+
inset-inline-start: 50%;
|
|
4534
|
+
transform: translateX(-50%);
|
|
4535
|
+
box-sizing: border-box;
|
|
4536
|
+
inline-size: max-content;
|
|
4537
|
+
max-inline-size: min(12rem, calc(100vw - 2rem));
|
|
4538
|
+
min-inline-size: 0;
|
|
4539
|
+
padding: 0.25rem 0.4rem;
|
|
4540
|
+
border: 1px solid CanvasText;
|
|
4541
|
+
border-radius: 0.25rem;
|
|
4542
|
+
background: Canvas;
|
|
4543
|
+
color: CanvasText;
|
|
4544
|
+
font-size: 0.75rem;
|
|
4545
|
+
font-weight: 700;
|
|
4546
|
+
line-height: 1.15;
|
|
4547
|
+
overflow-wrap: anywhere;
|
|
4548
|
+
pointer-events: none;
|
|
4549
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.16);
|
|
4550
|
+
text-align: center;
|
|
4551
|
+
white-space: normal;
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
@supports not (background: color-mix(in srgb, Highlight 18%, Canvas)) {
|
|
4555
|
+
.qti3-graphic-gap-hotspot[data-selected="true"] {
|
|
4556
|
+
background: Canvas;
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
4559
|
+
|
|
3766
4560
|
.qti3-graphic-order-hotspot {
|
|
3767
4561
|
display: grid;
|
|
3768
4562
|
place-items: center;
|